ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç badge_backpack_oauth2.php000064400000004275152266557740011453 0ustar00. /** * This file contains the form add/update oauth2 for backpack is connected. * * @package core_badges * @subpackage badges * @copyright 2020 Tung Thai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Tung Thai */ namespace core_badges\oauth2; defined('MOODLE_INTERNAL') || die(); use core\persistent; /** * Class badge_backpack_oauth2 for backpack is connected. * * @copyright 2020 Tung Thai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Tung Thai */ class badge_backpack_oauth2 extends persistent { /** * The table name. */ const TABLE = 'badge_backpack_oauth2'; /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return array( 'userid' => array( 'type' => PARAM_INT, ), 'issuerid' => array( 'type' => PARAM_INT ), 'externalbackpackid' => array( 'type' => PARAM_INT ), 'token' => array( 'type' => PARAM_TEXT ), 'refreshtoken' => array( 'type' => PARAM_TEXT ), 'expires' => array( 'type' => PARAM_INT ), 'scope' => array( 'type' => PARAM_TEXT ), ); } }client.php000064400000054641152266557740006570 0ustar00. /** * Configurable oauth2 client class. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/oauthlib.php'); require_once($CFG->libdir . '/filelib.php'); use moodle_url; use moodle_exception; use stdClass; /** * Configurable oauth2 client class. URLs come from DB and access tokens from either DB (system accounts) or session (users'). * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class client extends \oauth2_client { /** @var \core\oauth2\issuer $issuer */ private $issuer; /** @var bool $system */ protected $system = false; /** @var bool $autorefresh whether this client will use a refresh token to automatically renew access tokens.*/ protected $autorefresh = false; /** @var array $rawuserinfo Keep rawuserinfo from . */ protected $rawuserinfo = []; /** * Constructor. * * @param issuer $issuer * @param moodle_url|null $returnurl * @param string $scopesrequired * @param boolean $system * @param boolean $autorefresh whether refresh_token grants are used to allow continued access across sessions. */ public function __construct(issuer $issuer, $returnurl, $scopesrequired, $system = false, $autorefresh = false) { $this->issuer = $issuer; $this->system = $system; $this->autorefresh = $autorefresh; $scopes = $this->get_login_scopes(); $additionalscopes = explode(' ', $scopesrequired); foreach ($additionalscopes as $scope) { if (!empty($scope)) { if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) { $scopes .= ' ' . $scope; } } } if (empty($returnurl)) { $returnurl = new moodle_url('/'); } $this->basicauth = $issuer->get('basicauth'); parent::__construct($issuer->get('clientid'), $issuer->get('clientsecret'), $returnurl, $scopes); } /** * Returns the auth url for OAuth 2.0 request * @return string the auth url */ protected function auth_url() { return $this->issuer->get_endpoint_url('authorization'); } /** * Get the oauth2 issuer for this client. * * @return \core\oauth2\issuer Issuer */ public function get_issuer() { return $this->issuer; } /** * Override to append additional params to a authentication request. * * @return array (name value pairs). */ public function get_additional_login_parameters() { $params = ''; if ($this->system || $this->can_autorefresh()) { // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add // extra params to the login request, depending on the issuer settings. The extra params allow a refresh // token to be returned during the authorization_code flow. if (!empty($this->issuer->get('loginparamsoffline'))) { $params = $this->issuer->get('loginparamsoffline'); } } else { // This is not a system client, nor a client supporting the refresh_token grant type, so just return the // vanilla login params. if (!empty($this->issuer->get('loginparams'))) { $params = $this->issuer->get('loginparams'); } } if (empty($params)) { return []; } $result = []; // Replace the language tag if it appears in the string. $lang = current_language(); $tags = ["{lang}", "{LANG}", "{language}", "{LANGUAGE}", '{lan-guage}', '{LAN-GUAGE}']; $langcode = [ strtolower(substr($lang, 0, 2)), strtoupper(substr($lang, 0, 2)), strtolower($lang), strtoupper($lang), str_replace('_', '-', strtolower($lang)), str_replace('_', '-', strtoupper($lang)), ]; $params = str_replace($tags, $langcode, $params); parse_str($params, $result); return $result; } /** * Override to change the scopes requested with an authentiction request. * * @return string */ protected function get_login_scopes() { if ($this->system || $this->can_autorefresh()) { // System clients and clients supporting the refresh_token grant (provided the user is authenticated) add // extra scopes to the login request, depending on the issuer settings. The extra params allow a refresh // token to be returned during the authorization_code flow. return $this->issuer->get('loginscopesoffline'); } else { // This is not a system client, nor a client supporting the refresh_token grant type, so just return the // vanilla login scopes. return $this->issuer->get('loginscopes'); } } /** * Returns the token url for OAuth 2.0 request * * We are overriding the parent function so we get this from the configured endpoint. * * @return string the auth url */ protected function token_url() { return $this->issuer->get_endpoint_url('token'); } /** * We want a unique key for each issuer / and a different key for system vs user oauth. * * @return string The unique key for the session value. */ protected function get_tokenname() { $name = 'oauth2-state-' . $this->issuer->get('id'); if ($this->system) { $name .= '-system'; } return $name; } /** * Store a token between requests. Uses session named by get_tokenname for user account tokens * and a database record for system account tokens. * * @param stdClass|null $token token object to store or null to clear */ protected function store_token($token) { if (!$this->system) { parent::store_token($token); return; } $this->accesstoken = $token; // Create or update a DB record with the new token. $persistedtoken = access_token::get_record(['issuerid' => $this->issuer->get('id')]); if ($token !== null) { if (!$persistedtoken) { $persistedtoken = new access_token(); $persistedtoken->set('issuerid', $this->issuer->get('id')); } // Update values from $token. Don't use from_record because that would skip validation. $persistedtoken->set('token', $token->token); if (isset($token->expires)) { $persistedtoken->set('expires', $token->expires); } else { // Assume an arbitrary time span of 1 week for access tokens without expiration. // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long. $persistedtoken->set('expires', time() + WEEKSECS); } $persistedtoken->set('scope', $token->scope); $persistedtoken->save(); } else { if ($persistedtoken) { $persistedtoken->delete(); } } } /** * Retrieve a stored token from session (user accounts) or database (system accounts). * * @return stdClass|null token object */ protected function get_stored_token() { if ($this->system) { $token = access_token::get_record(['issuerid' => $this->issuer->get('id')]); if ($token !== false) { return $token->to_record(); } return null; } return parent::get_stored_token(); } /** * Get a list of the mapping user fields in an associative array. * * @return array */ protected function get_userinfo_mapping() { $fields = user_field_mapping::get_records(['issuerid' => $this->issuer->get('id')]); $map = []; foreach ($fields as $field) { $map[$field->get('externalfield')] = $field->get('internalfield'); } return $map; } /** * Override which upgrades the authorization code to an access token and stores any refresh token in the DB. * * @param string $code the authorisation code * @return bool true if the token could be upgraded * @throws moodle_exception */ public function upgrade_token($code) { $upgraded = parent::upgrade_token($code); if (!$this->can_autorefresh()) { return $upgraded; } // For clients supporting auto-refresh, try to store a refresh token. if (!empty($this->refreshtoken)) { $refreshtoken = (object) [ 'token' => $this->refreshtoken, 'scope' => $this->scope ]; $this->store_user_refresh_token($refreshtoken); } return $upgraded; } /** * Override which in addition to auth code upgrade, also attempts to exchange a refresh token for an access token. * * @return bool true if the user is logged in as a result, false otherwise. */ public function is_logged_in() { global $DB, $USER; $isloggedin = parent::is_logged_in(); // Attempt to exchange a user refresh token, but only if required and supported. if ($isloggedin || !$this->can_autorefresh()) { return $isloggedin; } // Autorefresh is supported. Try to negotiate a login by exchanging a stored refresh token for an access token. $issuerid = $this->issuer->get('id'); $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid]); if ($refreshtoken) { try { $tokensreceived = $this->exchange_refresh_token($refreshtoken->token); if (empty($tokensreceived)) { // No access token was returned, so invalidate the refresh token and return false. $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]); return false; } // Otherwise, save the access token and, if provided, the new refresh token. $this->store_token($tokensreceived['access_token']); if (!empty($tokensreceived['refresh_token'])) { $this->store_user_refresh_token($tokensreceived['refresh_token']); } return true; } catch (\moodle_exception $e) { // The refresh attempt failed either due to an error or a bad request. A bad request could be received // for a number of reasons including expired refresh token (lifetime is not specified in OAuth 2 spec), // scope change or if app access has been revoked manually by the user (tokens revoked). // Remove the refresh token and suppress the exception, allowing the user to be taken through the // authorization_code flow again. $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]); } } return false; } /** * Whether this client should automatically exchange a refresh token for an access token as part of login checks. * * @return bool true if supported, false otherwise. */ protected function can_autorefresh(): bool { global $USER; // Auto refresh is only supported when the follow criteria are met: // a) The client is not a system client. The exchange process for system client refresh tokens is handled // externally, via a call to client->upgrade_refresh_token(). // b) The user is authenticated. // c) The client has been configured with autorefresh enabled. return !$this->system && ($this->autorefresh && !empty($USER->id)); } /** * Store the user's refresh token for later use. * * @param stdClass $token a refresh token. */ protected function store_user_refresh_token(stdClass $token): void { global $DB, $USER; $id = $DB->get_field('oauth2_refresh_token', 'id', ['userid' => $USER->id, 'scopehash' => sha1($token->scope), 'issuerid' => $this->issuer->get('id')]); $time = time(); if ($id) { $record = [ 'id' => $id, 'timemodified' => $time, 'token' => $token->token ]; $DB->update_record('oauth2_refresh_token', $record); } else { $record = [ 'timecreated' => $time, 'timemodified' => $time, 'userid' => $USER->id, 'issuerid' => $this->issuer->get('id'), 'token' => $token->token, 'scopehash' => sha1($token->scope) ]; $DB->insert_record('oauth2_refresh_token', $record); } } /** * Attempt to exchange a refresh token for a new access token. * * If successful, will return an array of token objects in the form: * Array * ( * [access_token] => stdClass object * ( * [token] => 'the_token_string' * [expires] => 123456789 * [scope] => 'openid files etc' * ) * [refresh_token] => stdClass object * ( * [token] => 'the_refresh_token_string' * [scope] => 'openid files etc' * ) * ) * where the 'refresh_token' will only be provided if supplied by the auth server in the response. * * @param string $refreshtoken the refresh token to exchange. * @return null|array array containing access token and refresh token if provided, null if the exchange was denied. * @throws moodle_exception if an invalid response is received or if the response contains errors. */ protected function exchange_refresh_token(string $refreshtoken): ?array { $params = array('refresh_token' => $refreshtoken, 'grant_type' => 'refresh_token' ); if ($this->basicauth) { $idsecret = urlencode($this->issuer->get('clientid')) . ':' . urlencode($this->issuer->get('clientsecret')); $this->setHeader('Authorization: Basic ' . base64_encode($idsecret)); } else { $params['client_id'] = $this->issuer->get('clientid'); $params['client_secret'] = $this->issuer->get('clientsecret'); } // Requests can either use http GET or POST. if ($this->use_http_get()) { $response = $this->get($this->token_url(), $params); } else { $response = $this->post($this->token_url(), $this->build_post_data($params)); } if ($this->info['http_code'] !== 200) { $debuginfo = !empty($this->error) ? $this->error : $response; throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo); } $r = json_decode($response); if (!empty($r->error)) { throw new moodle_exception($r->error . ' ' . $r->error_description); } if (!isset($r->access_token)) { return null; } // Store the token an expiry time. $accesstoken = new stdClass(); $accesstoken->token = $r->access_token; if (isset($r->expires_in)) { // Expires 10 seconds before actual expiry. $accesstoken->expires = (time() + ($r->expires_in - 10)); } $accesstoken->scope = $this->scope; $tokens = ['access_token' => $accesstoken]; if (isset($r->refresh_token)) { $this->refreshtoken = $r->refresh_token; $newrefreshtoken = new stdClass(); $newrefreshtoken->token = $this->refreshtoken; $newrefreshtoken->scope = $this->scope; $tokens['refresh_token'] = $newrefreshtoken; } return $tokens; } /** * Override which, in addition to deleting access tokens, also deletes any stored refresh token. */ public function log_out() { global $DB, $USER; parent::log_out(); if (!$this->can_autorefresh()) { return; } // For clients supporting autorefresh, delete the stored refresh token too. $issuerid = $this->issuer->get('id'); $refreshtoken = $DB->get_record('oauth2_refresh_token', ['userid' => $USER->id, 'issuerid' => $issuerid, 'scopehash' => sha1($this->scope)]); if ($refreshtoken) { $DB->delete_records('oauth2_refresh_token', ['id' => $refreshtoken->id]); } } /** * Upgrade a refresh token from oauth 2.0 to an access token, for system clients only. * * @param \core\oauth2\system_account $systemaccount * @return boolean true if token is upgraded succesfully */ public function upgrade_refresh_token(system_account $systemaccount) { $receivedtokens = $this->exchange_refresh_token($systemaccount->get('refreshtoken')); // No access token received, so return false. if (empty($receivedtokens)) { return false; } // Store the access token and, if provided by the server, the new refresh token. $this->store_token($receivedtokens['access_token']); if (isset($receivedtokens['refresh_token'])) { $systemaccount->set('refreshtoken', $receivedtokens['refresh_token']->token); $systemaccount->update(); } return true; } /** * Fetch the user info from the user info endpoint. * * @return stdClass|false Moodle user fields for the logged in user (or false if request failed) * @throws moodle_exception if the response is empty after decoding it. */ public function get_raw_userinfo() { if (!empty($this->rawuserinfo)) { return $this->rawuserinfo; } $url = $this->get_issuer()->get_endpoint_url('userinfo'); if (empty($url)) { return false; } $response = $this->get($url); if (!$response) { return false; } $userinfo = new stdClass(); try { $userinfo = json_decode($response); } catch (\Exception $e) { return false; } if (is_null($userinfo)) { // Throw an exception displaying the original response, because, at this point, $userinfo shouldn't be empty. throw new moodle_exception($response); } $this->rawuserinfo = $userinfo; return $userinfo; } /** * Fetch the user info from the user info endpoint and map all * the fields back into moodle fields. * * @return stdClass|false Moodle user fields for the logged in user (or false if request failed) * @throws moodle_exception if the response is empty after decoding it. */ public function get_userinfo() { $userinfo = $this->get_raw_userinfo(); if ($userinfo === false) { return false; } return $this->map_userinfo_to_fields($userinfo); } /** * Maps the oauth2 response to userfields. * * @param stdClass $userinfo * @return array */ protected function map_userinfo_to_fields(stdClass $userinfo): array { $map = $this->get_userinfo_mapping(); $user = new stdClass(); foreach ($map as $openidproperty => $moodleproperty) { // We support nested objects via a-b-c syntax. $getfunc = function($obj, $prop) use (&$getfunc) { $proplist = explode('-', $prop, 2); // The value of proplist[0] can be falsey, so just check if not set. if (empty($obj) || !isset($proplist[0])) { return false; } if (preg_match('/^(.*)\[([0-9]*)\]$/', $proplist[0], $matches) && count($matches) == 3) { $property = $matches[1]; $index = $matches[2]; $obj = $obj->{$property}[$index] ?? null; } else if (!empty($obj->{$proplist[0]})) { $obj = $obj->{$proplist[0]}; } else if (is_array($obj) && !empty($obj[$proplist[0]])) { $obj = $obj[$proplist[0]]; } else { // Nothing found after checking all possible valid combinations, return false. return false; } if (count($proplist) > 1) { return $getfunc($obj, $proplist[1]); } return $obj; }; $resolved = $getfunc($userinfo, $openidproperty); if (!empty($resolved)) { $user->$moodleproperty = $resolved; } } if (empty($user->username) && !empty($user->email)) { $user->username = $user->email; } if (!empty($user->picture)) { $user->picture = download_file_content($user->picture, null, null, false, 10, 10, true, null, false); } else { $pictureurl = $this->issuer->get_endpoint_url('userpicture'); if (!empty($pictureurl)) { $user->picture = $this->get($pictureurl); } } if (!empty($user->picture)) { // If it doesn't look like a picture lets unset it. if (function_exists('imagecreatefromstring')) { $img = @imagecreatefromstring($user->picture); if (empty($img)) { unset($user->picture); } else { imagedestroy($img); } } } return (array)$user; } } auth.php000064400000006222152266557740006243 0ustar00. /** * This file to proccess Oauth2 connects for backpack. * * @package core_badges * @subpackage badges * @copyright 2020 Tung Thai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Tung Thai */ namespace core_badges\oauth2; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/authlib.php'); use stdClass; /** * Proccess Oauth2 connects to backpack site. * * @copyright 2020 Tung Thai * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Tung Thai */ class auth extends \auth_oauth2\auth { /** * To complete data after login. * * @param client $client object. * @param string $redirecturl the url redirect. */ public function complete_data(\core_badges\oauth2\client $client, $redirecturl) { global $DB, $USER; $userinfo = $client->get_userinfo(); $badgebackpack = new stdClass(); $badgebackpack->userid = $USER->id; if ($userinfo && isset($userinfo->email)) { $badgebackpack->email = $userinfo->email; } else { $badgebackpack->email = $USER->email; } $badgebackpack->externalbackpackid = $client->backpack->id; $badgebackpack->backpackuid = 0; $badgebackpack->autosync = 0; $badgebackpack->password = ''; $record = $DB->get_record('badge_backpack', ['userid' => $USER->id, 'externalbackpackid' => $client->backpack->id]); if (!$record) { $DB->insert_record('badge_backpack', $badgebackpack); } else { $badgebackpack->id = $record->id; $DB->update_record('badge_backpack', $badgebackpack); } redirect($redirecturl, get_string('backpackconnected', 'badges'), null, \core\output\notification::NOTIFY_SUCCESS); } /** * Check user has been logged the backpack site. * * @param int $externalbackpackid ID of external backpack. * @param int $userid ID of user. * @return bool */ public static function is_logged_oauth2($externalbackpackid, $userid) { global $USER; if (empty($userid)) { $userid = $USER->id; } $persistedtoken = badge_backpack_oauth2::get_record(['externalbackpackid' => $externalbackpackid, 'userid' => $userid]); if ($persistedtoken) { return true; } return false; } } refresh_system_tokens_task.php000064400000007420152267211100012724 0ustar00. /** * A scheduled task. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; use \core\task\scheduled_task; use core_user; use moodle_exception; defined('MOODLE_INTERNAL') || die(); /** * Task to refresh system tokens regularly. Admins are notified in case an authorisation expires. * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class refresh_system_tokens_task extends scheduled_task { /** * Get a descriptive name for this task (shown to admins). * * @return string */ public function get_name() { return get_string('taskrefreshsystemtokens', 'admin'); } /** * Notify admins when an OAuth refresh token expires. Should not happen if cron is running regularly. * @param \core\oauth2\issuer $issuer */ protected function notify_admins(\core\oauth2\issuer $issuer) { global $CFG; $admins = get_admins(); if (empty($admins)) { return; } foreach ($admins as $admin) { $strparams = ['siteurl' => $CFG->wwwroot, 'issuer' => $issuer->get('name')]; $long = get_string('oauthrefreshtokenexpired', 'core_admin', $strparams); $short = get_string('oauthrefreshtokenexpiredshort', 'core_admin', $strparams); $message = new \core\message\message(); $message->courseid = SITEID; $message->component = 'moodle'; $message->name = 'errors'; $message->userfrom = core_user::get_noreply_user(); $message->userto = $admin; $message->subject = $short; $message->fullmessage = $long; $message->fullmessageformat = FORMAT_PLAIN; $message->fullmessagehtml = $long; $message->smallmessage = $short; $message->notification = 1; message_send($message); } } /** * Do the job. * Throw exceptions on errors (the job will be retried). */ public function execute() { $issuers = \core\oauth2\api::get_all_issuers(true); $tasksuccess = true; foreach ($issuers as $issuer) { if ($issuer->is_system_account_connected()) { try { // Try to get an authenticated client; renew token if necessary. // Returns false or throws a moodle_exception on error. $success = \core\oauth2\api::get_system_oauth_client($issuer); } catch (moodle_exception $e) { mtrace($e->getMessage()); $success = false; } if ($success === false) { $this->notify_admins($issuer); $tasksuccess = false; } } } if (!$tasksuccess) { throw new moodle_exception('oauth2refreshtokentaskerror', 'core_error'); } } } api.php000064400000053446152267211100006037 0ustar00. /** * Class for loading/storing oauth2 endpoints from the DB. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/filelib.php'); use stdClass; use moodle_url; use context_system; use moodle_exception; /** * Static list of api methods for system oauth2 configuration. * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class api { /** * Initializes a record for one of the standard issuers to be displayed in the settings. * The issuer is not yet created in the database. * @param string $type One of google, facebook, microsoft, nextcloud, imsobv2p1 * @return \core\oauth2\issuer */ public static function init_standard_issuer($type) { require_capability('moodle/site:config', context_system::instance()); $classname = self::get_service_classname($type); if (class_exists($classname)) { return $classname::init(); } throw new moodle_exception('OAuth 2 service type not recognised: ' . $type); } /** * Create endpoints for standard issuers, based on the issuer created from submitted data. * @param string $type One of google, facebook, microsoft, nextcloud, imsobv2p1 * @param issuer $issuer issuer the endpoints should be created for. * @return \core\oauth2\issuer */ public static function create_endpoints_for_standard_issuer($type, $issuer) { require_capability('moodle/site:config', context_system::instance()); $classname = self::get_service_classname($type); if (class_exists($classname)) { $classname::create_endpoints($issuer); return $issuer; } throw new moodle_exception('OAuth 2 service type not recognised: ' . $type); } /** * Create one of the standard issuers. * * @param string $type One of google, facebook, microsoft, MoodleNet, nextcloud or imsobv2p1 * @param string|false $baseurl Baseurl (only required for nextcloud, imsobv2p1 and moodlenet) * @return \core\oauth2\issuer */ public static function create_standard_issuer($type, $baseurl = false) { require_capability('moodle/site:config', context_system::instance()); switch ($type) { case 'imsobv2p1': if (!$baseurl) { throw new moodle_exception('IMS OBv2.1 service type requires the baseurl parameter.'); } case 'nextcloud': if (!$baseurl) { throw new moodle_exception('Nextcloud service type requires the baseurl parameter.'); } case 'moodlenet': if (!$baseurl) { throw new moodle_exception('MoodleNet service type requires the baseurl parameter.'); } case 'google': case 'facebook': case 'microsoft': $classname = self::get_service_classname($type); $issuer = $classname::init(); if ($baseurl) { $issuer->set('baseurl', $baseurl); } $issuer->create(); return self::create_endpoints_for_standard_issuer($type, $issuer); } throw new moodle_exception('OAuth 2 service type not recognised: ' . $type); } /** * List all the issuers, ordered by the sortorder field * * @param bool $includeloginonly also include issuers that are configured to be shown only on login page, * By default false, in this case the method returns all issuers that can be used in services * @return \core\oauth2\issuer[] */ public static function get_all_issuers(bool $includeloginonly = false) { if ($includeloginonly) { return issuer::get_records([], 'sortorder'); } else { return array_values(issuer::get_records_select('showonloginpage<>?', [issuer::LOGINONLY], 'sortorder')); } } /** * Get a single issuer by id. * * @param int $id * @return \core\oauth2\issuer */ public static function get_issuer($id) { return new issuer($id); } /** * Get a single endpoint by id. * * @param int $id * @return \core\oauth2\endpoint */ public static function get_endpoint($id) { return new endpoint($id); } /** * Get a single user field mapping by id. * * @param int $id * @return \core\oauth2\user_field_mapping */ public static function get_user_field_mapping($id) { return new user_field_mapping($id); } /** * Get the system account for an installed OAuth service. * Never ever ever expose this to a webservice because it contains the refresh token which grants API access. * * @param \core\oauth2\issuer $issuer * @return system_account|false */ public static function get_system_account(issuer $issuer) { return system_account::get_record(['issuerid' => $issuer->get('id')]); } /** * Get the full list of system scopes required by an oauth issuer. * This includes the list required for login as well as any scopes injected by the oauth2_system_scopes callback in plugins. * * @param \core\oauth2\issuer $issuer * @return string */ public static function get_system_scopes_for_issuer($issuer) { $scopes = $issuer->get('loginscopesoffline'); $pluginsfunction = get_plugins_with_function('oauth2_system_scopes', 'lib.php'); foreach ($pluginsfunction as $plugintype => $plugins) { foreach ($plugins as $pluginfunction) { // Get additional scopes from the plugin. $pluginscopes = $pluginfunction($issuer); if (empty($pluginscopes)) { continue; } // Merge the additional scopes with the existing ones. $additionalscopes = explode(' ', $pluginscopes); foreach ($additionalscopes as $scope) { if (!empty($scope)) { if (strpos(' ' . $scopes . ' ', ' ' . $scope . ' ') === false) { $scopes .= ' ' . $scope; } } } } } return $scopes; } /** * Get an authenticated oauth2 client using the system account. * This call uses the refresh token to get an access token. * * @param \core\oauth2\issuer $issuer * @return \core\oauth2\client|false An authenticated client (or false if the token could not be upgraded) * @throws moodle_exception Request for token upgrade failed for technical reasons */ public static function get_system_oauth_client(issuer $issuer) { $systemaccount = self::get_system_account($issuer); if (empty($systemaccount)) { return false; } // Get all the scopes! $scopes = self::get_system_scopes_for_issuer($issuer); $class = self::get_client_classname($issuer->get('servicetype')); $client = new $class($issuer, null, $scopes, true); if (!$client->is_logged_in()) { if (!$client->upgrade_refresh_token($systemaccount)) { return false; } } return $client; } /** * Get an authenticated oauth2 client using the current user account. * This call does the redirect dance back to the current page after authentication. * * @param \core\oauth2\issuer $issuer The desired OAuth issuer * @param moodle_url $currenturl The url to the current page. * @param string $additionalscopes The additional scopes required for authorization. * @param bool $autorefresh Should the client support the use of refresh tokens to persist access across sessions. * @return \core\oauth2\client */ public static function get_user_oauth_client(issuer $issuer, moodle_url $currenturl, $additionalscopes = '', $autorefresh = false) { $class = self::get_client_classname($issuer->get('servicetype')); $client = new $class($issuer, $currenturl, $additionalscopes, false, $autorefresh); return $client; } /** * Get the client classname for an issuer. * * @param string $type The OAuth issuer type (google, facebook...). * @return string The classname for the custom client or core client class if the class for the defined type * doesn't exist or null type is defined. */ protected static function get_client_classname(?string $type): string { // Default core client class. $classname = 'core\\oauth2\\client'; if (!empty($type)) { $typeclassname = 'core\\oauth2\\client\\' . $type; if (class_exists($typeclassname)) { $classname = $typeclassname; } } return $classname; } /** * Get the list of defined endpoints for this OAuth issuer * * @param \core\oauth2\issuer $issuer The desired OAuth issuer * @return \core\oauth2\endpoint[] */ public static function get_endpoints(issuer $issuer) { return endpoint::get_records(['issuerid' => $issuer->get('id')]); } /** * Get the list of defined mapping from OAuth user fields to moodle user fields. * * @param \core\oauth2\issuer $issuer The desired OAuth issuer * @return \core\oauth2\user_field_mapping[] */ public static function get_user_field_mappings(issuer $issuer) { return user_field_mapping::get_records(['issuerid' => $issuer->get('id')]); } /** * Guess an image from the discovery URL. * * @param \core\oauth2\issuer $issuer The desired OAuth issuer */ protected static function guess_image($issuer) { if (empty($issuer->get('image')) && !empty($issuer->get('baseurl'))) { $baseurl = parse_url($issuer->get('baseurl')); $imageurl = $baseurl['scheme'] . '://' . $baseurl['host'] . '/favicon.ico'; $issuer->set('image', $imageurl); $issuer->update(); } } /** * Take the data from the mform and update the issuer. * * @param stdClass $data * @return \core\oauth2\issuer */ public static function update_issuer($data) { return self::create_or_update_issuer($data, false); } /** * Take the data from the mform and create the issuer. * * @param stdClass $data * @return \core\oauth2\issuer */ public static function create_issuer($data) { return self::create_or_update_issuer($data, true); } /** * Take the data from the mform and create or update the issuer. * * @param stdClass $data Form data for them issuer to be created/updated. * @param bool $create If true, the issuer will be created; otherwise, it will be updated. * @return issuer The created/updated issuer. */ protected static function create_or_update_issuer($data, bool $create): issuer { require_capability('moodle/site:config', context_system::instance()); $issuer = new issuer($data->id ?? 0, $data); // Will throw exceptions on validation failures. if ($create) { $issuer->create(); // Perform service discovery. $classname = self::get_service_classname($issuer->get('servicetype')); $classname::discover_endpoints($issuer); self::guess_image($issuer); } else { $issuer->update(); } return $issuer; } /** * Get the service classname for an issuer. * * @param string $type The OAuth issuer type (google, facebook...). * * @return string The classname for this issuer or "Custom" service class if the class for the defined type doesn't exist * or null type is defined. */ protected static function get_service_classname(?string $type): string { // Default custom service class. $classname = 'core\\oauth2\\service\\custom'; if (!empty($type)) { $typeclassname = 'core\\oauth2\\service\\' . $type; if (class_exists($typeclassname)) { $classname = $typeclassname; } } return $classname; } /** * Take the data from the mform and update the endpoint. * * @param stdClass $data * @return \core\oauth2\endpoint */ public static function update_endpoint($data) { require_capability('moodle/site:config', context_system::instance()); $endpoint = new endpoint(0, $data); // Will throw exceptions on validation failures. $endpoint->update(); return $endpoint; } /** * Take the data from the mform and create the endpoint. * * @param stdClass $data * @return \core\oauth2\endpoint */ public static function create_endpoint($data) { require_capability('moodle/site:config', context_system::instance()); $endpoint = new endpoint(0, $data); // Will throw exceptions on validation failures. $endpoint->create(); return $endpoint; } /** * Take the data from the mform and update the user field mapping. * * @param stdClass $data * @return \core\oauth2\user_field_mapping */ public static function update_user_field_mapping($data) { require_capability('moodle/site:config', context_system::instance()); $userfieldmapping = new user_field_mapping(0, $data); // Will throw exceptions on validation failures. $userfieldmapping->update(); return $userfieldmapping; } /** * Take the data from the mform and create the user field mapping. * * @param stdClass $data * @return \core\oauth2\user_field_mapping */ public static function create_user_field_mapping($data) { require_capability('moodle/site:config', context_system::instance()); $userfieldmapping = new user_field_mapping(0, $data); // Will throw exceptions on validation failures. $userfieldmapping->create(); return $userfieldmapping; } /** * Reorder this identity issuer. * * Requires moodle/site:config capability at the system context. * * @param int $id The id of the identity issuer to move. * @return boolean */ public static function move_up_issuer($id) { require_capability('moodle/site:config', context_system::instance()); $current = new issuer($id); $sortorder = $current->get('sortorder'); if ($sortorder == 0) { return false; } $sortorder = $sortorder - 1; $current->set('sortorder', $sortorder); $filters = array('sortorder' => $sortorder); $children = issuer::get_records($filters, 'id'); foreach ($children as $needtoswap) { $needtoswap->set('sortorder', $sortorder + 1); $needtoswap->update(); } // OK - all set. $result = $current->update(); return $result; } /** * Reorder this identity issuer. * * Requires moodle/site:config capability at the system context. * * @param int $id The id of the identity issuer to move. * @return boolean */ public static function move_down_issuer($id) { require_capability('moodle/site:config', context_system::instance()); $current = new issuer($id); $max = issuer::count_records(); if ($max > 0) { $max--; } $sortorder = $current->get('sortorder'); if ($sortorder >= $max) { return false; } $sortorder = $sortorder + 1; $current->set('sortorder', $sortorder); $filters = array('sortorder' => $sortorder); $children = issuer::get_records($filters); foreach ($children as $needtoswap) { $needtoswap->set('sortorder', $sortorder - 1); $needtoswap->update(); } // OK - all set. $result = $current->update(); return $result; } /** * Disable an identity issuer. * * Requires moodle/site:config capability at the system context. * * @param int $id The id of the identity issuer to disable. * @return boolean */ public static function disable_issuer($id) { require_capability('moodle/site:config', context_system::instance()); $issuer = new issuer($id); $issuer->set('enabled', 0); return $issuer->update(); } /** * Enable an identity issuer. * * Requires moodle/site:config capability at the system context. * * @param int $id The id of the identity issuer to enable. * @return boolean */ public static function enable_issuer($id) { require_capability('moodle/site:config', context_system::instance()); $issuer = new issuer($id); $issuer->set('enabled', 1); return $issuer->update(); } /** * Delete an identity issuer. * * Requires moodle/site:config capability at the system context. * * @param int $id The id of the identity issuer to delete. * @return boolean */ public static function delete_issuer($id) { require_capability('moodle/site:config', context_system::instance()); $issuer = new issuer($id); $systemaccount = self::get_system_account($issuer); if ($systemaccount) { $systemaccount->delete(); } $endpoints = self::get_endpoints($issuer); if ($endpoints) { foreach ($endpoints as $endpoint) { $endpoint->delete(); } } // Will throw exceptions on validation failures. return $issuer->delete(); } /** * Delete an endpoint. * * Requires moodle/site:config capability at the system context. * * @param int $id The id of the endpoint to delete. * @return boolean */ public static function delete_endpoint($id) { require_capability('moodle/site:config', context_system::instance()); $endpoint = new endpoint($id); // Will throw exceptions on validation failures. return $endpoint->delete(); } /** * Delete a user_field_mapping. * * Requires moodle/site:config capability at the system context. * * @param int $id The id of the user_field_mapping to delete. * @return boolean */ public static function delete_user_field_mapping($id) { require_capability('moodle/site:config', context_system::instance()); $userfieldmapping = new user_field_mapping($id); // Will throw exceptions on validation failures. return $userfieldmapping->delete(); } /** * Perform the OAuth dance and get a refresh token. * * Requires moodle/site:config capability at the system context. * * @param \core\oauth2\issuer $issuer * @param moodle_url $returnurl The url to the current page (we will be redirected back here after authentication). * @return boolean */ public static function connect_system_account($issuer, $returnurl) { require_capability('moodle/site:config', context_system::instance()); // We need to authenticate with an oauth 2 client AS a system user and get a refresh token for offline access. $scopes = self::get_system_scopes_for_issuer($issuer); // Allow callbacks to inject non-standard scopes to the auth request. $class = self::get_client_classname($issuer->get('servicetype')); $client = new $class($issuer, $returnurl, $scopes, true); if (!optional_param('response', false, PARAM_BOOL)) { $client->log_out(); } if (optional_param('error', '', PARAM_RAW)) { return false; } if (!$client->is_logged_in()) { redirect($client->get_login_url()); } $refreshtoken = $client->get_refresh_token(); if (!$refreshtoken) { return false; } $systemaccount = self::get_system_account($issuer); if ($systemaccount) { $systemaccount->delete(); } $userinfo = $client->get_userinfo(); $record = new stdClass(); $record->issuerid = $issuer->get('id'); $record->refreshtoken = $refreshtoken; $record->grantedscopes = $scopes; // Get email. if (isset($userinfo['email'])) { $record->email = $userinfo['email']; } else if ($issuer->get_system_email()) { $record->email = $issuer->get_system_email(); } else { $record->email = ''; } // Get username. if (isset($userinfo['username'])) { $record->username = $userinfo['username']; } else if ($issuer->get_system_email()) { $record->username = $issuer->get_system_email(); } $systemaccount = new system_account(0, $record); $systemaccount->create(); $client->log_out(); return true; } } system_account.php000064400000003566152267211100010324 0ustar00. /** * When using OAuth sometimes it makes sense to authenticate as a system user, and not the current user. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; defined('MOODLE_INTERNAL') || die(); use core\persistent; /** * Class for loading/storing oauth2 refresh tokens from the DB. * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class system_account extends persistent { const TABLE = 'oauth2_system_account'; /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return array( 'issuerid' => array( 'type' => PARAM_INT ), 'refreshtoken' => array( 'type' => PARAM_RAW, ), 'grantedscopes' => array( 'type' => PARAM_RAW, ), 'email' => array( 'type' => PARAM_RAW, ), 'username' => array( 'type' => PARAM_RAW, ) ); } } service/issuer_interface.php000064400000003376152267211100012255 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; /** * Interface for services, with the methods to be implemented by all the issuer implementing it. * * @package core * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer|null The issuer initialised with proper default values, or null if no issuer is initialised. */ public static function init(): ?issuer; /** * Create endpoints for this issuer. * * @param issuer $issuer Issuer the endpoints should be created for. * @return issuer */ public static function create_endpoints(issuer $issuer): issuer; /** * If the discovery endpoint exists for this issuer, try and determine the list of valid endpoints. * * @param issuer $issuer * @return int The number of discovered services. */ public static function discover_endpoints($issuer): int; } service/clever.php000064400000004405152267211100010175 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; use core\oauth2\discovery\openidconnect; use core\oauth2\user_field_mapping; /** * Class for Clever OAuth service, with the specific methods related to it. * * @package core * @copyright 2022 OpenStax * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class clever extends openidconnect implements issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer The issuer initialised with proper default values. */ public static function init(): issuer { $record = (object) [ 'name' => 'Clever', 'image' => 'https://apps.clever.com/favicon.ico', 'basicauth' => 1, 'baseurl' => 'https://clever.com', 'showonloginpage' => issuer::LOGINONLY, 'servicetype' => 'clever', ]; return new issuer(0, $record); } /** * Create field mappings for this issuer. * * @param issuer $issuer Issuer the field mappings should be created for. */ public static function create_field_mappings(issuer $issuer): void { // Perform OIDC default field mapping. parent::create_field_mappings($issuer); // Create the additional 'sub' field mapping. $record = (object) [ 'issuerid' => $issuer->get('id'), 'externalfield' => 'sub', 'internalfield' => 'idnumber', ]; $userfieldmapping = new user_field_mapping(0, $record); $userfieldmapping->create(); } } service/imsobv2p1.php000064400000003522152267211100010536 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; use core\oauth2\discovery\imsbadgeconnect; /** * Class for IMS Open Badges v2.1 oAuth service, with the specific methods related to it. * * @package core * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class imsobv2p1 extends imsbadgeconnect implements issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer|null The issuer initialised with proper default values. */ public static function init(): ?issuer { $record = (object) [ 'name' => 'Open Badges', 'image' => '', 'servicetype' => 'imsobv2p1', ]; $issuer = new issuer(0, $record); return $issuer; } /** * Process how to map user field information. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @return void */ public static function create_field_mappings(issuer $issuer): void { // There are no specific field mappings for this service. } } service/linkedin.php000064400000005042152267211100010510 0ustar00. namespace core\oauth2\service; use core\oauth2\discovery\openidconnect; use core\oauth2\issuer; /** * Class linkedin. * * OAuth 2 issuer for linkedin which is mostly OIDC compliant, with a few notable exceptions which require working around: * * 1. LinkedIn don't provide their OIDC discovery doc at {ISSUER}/.well-known/openid-configuration as the spec requires. * i.e. https://www.linkedin.com/.well-known/openid-configuration isn't present. * Instead, they make the configuration available at https://www.linkedin.com/oauth/.well-known/openid-configuration. * See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig * * 2. LinkedIn don't return 'locale' as a string in the userinfo but instead return an object with 'language' and 'country' props. * See: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims * This is resolved in {@see \core\oauth2\client\linkedin::get_userinfo()} * * @copyright 2021 Peter Dias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @package core */ class linkedin extends openidconnect { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer The issuer initialised with proper default values. */ public static function init(): issuer { $record = (object) [ 'name' => 'LinkedIn', 'image' => 'https://static.licdn.com/scds/common/u/images/logos/favicons/v1/favicon.ico', 'baseurl' => 'https://www.linkedin.com/oauth', // The /oauth is where .well-known/openid-configuration lives. 'loginscopes' => 'openid profile email', 'loginscopesoffline' => 'openid profile email', 'showonloginpage' => issuer::EVERYWHERE, 'servicetype' => 'linkedin', ]; $issuer = new issuer(0, $record); return $issuer; } } service/moodlenet.php000064400000015203152267211100010701 0ustar00. namespace core\oauth2\service; use core\http_client; use core\oauth2\discovery\auth_server_config_reader; use core\oauth2\endpoint; use core\oauth2\issuer; use GuzzleHttp\Psr7\Request; /** * MoodleNet OAuth 2 configuration. * * @package core * @copyright 2023 Jake Dallimore * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class moodlenet implements issuer_interface { /** * Get the issuer template to display in the form. * * @return issuer the issuer. */ public static function init(): ?issuer { $record = (object) [ 'name' => 'MoodleNet', 'image' => 'https://moodle.net/favicon.ico', 'baseurl' => 'https://moodle.net', 'loginscopes' => '', 'loginscopesoffline' => '', 'loginparamsoffline' => '', 'showonloginpage' => issuer::SERVICEONLY, 'servicetype' => 'moodlenet', ]; $issuer = new issuer(0, $record); return $issuer; } /** * Create the endpoints for the issuer. * * @param issuer $issuer the issuer instance. * @return issuer the issuer instance. */ public static function create_endpoints(issuer $issuer): issuer { self::discover_endpoints($issuer); return $issuer; } /** * Read the OAuth 2 Auth Server Metadata. * * @param issuer $issuer the issuer instance. * @return int the number of endpoints created. */ public static function discover_endpoints($issuer): int { $baseurl = $issuer->get('baseurl'); if (empty($baseurl)) { return 0; } $endpointscreated = 0; $config = []; if (defined('BEHAT_SITE_RUNNING')) { $config['verify'] = false; } $configreader = new auth_server_config_reader(new http_client($config)); try { $config = $configreader->read_configuration(new \moodle_url($baseurl)); foreach ($config as $key => $value) { if (substr_compare($key, '_endpoint', -strlen('_endpoint')) === 0) { $record = new \stdClass(); $record->issuerid = $issuer->get('id'); $record->name = $key; $record->url = $value; $endpoint = new endpoint(0, $record); $endpoint->create(); $endpointscreated++; } if ($key == 'scopes_supported') { $issuer->set('scopessupported', implode(' ', $value)); $issuer->update(); } } } catch (\Exception $e) { throw new \moodle_exception('Could not read service configuration for issuer: ' . $issuer->get('name')); } try { self::client_registration($issuer); } catch (\Exception $e) { throw new \moodle_exception('Could not register client for issuer: ' . $issuer->get('name')); } return $endpointscreated; } /** * Perform (open) OAuth 2 Dynamic Client Registration with the MoodleNet application. * * @param issuer $issuer the issuer instance containing the service baseurl. * @return void */ protected static function client_registration(issuer $issuer): void { global $CFG, $SITE; $clientid = $issuer->get('clientid'); $clientsecret = $issuer->get('clientsecret'); if (empty($clientid) && empty($clientsecret)) { $url = $issuer->get_endpoint_url('registration'); if ($url) { $scopes = str_replace("\r", " ", $issuer->get('scopessupported')); $hosturl = $CFG->wwwroot; $request = [ 'client_name' => $SITE->fullname, 'client_uri' => $hosturl, 'logo_uri' => $hosturl . '/pix/moodlelogo.png', 'tos_uri' => $hosturl, 'policy_uri' => $hosturl, 'software_id' => 'moodle', 'software_version' => $CFG->version, 'redirect_uris' => [ $hosturl . '/admin/oauth2callback.php' ], 'token_endpoint_auth_method' => 'client_secret_basic', 'grant_types' => [ 'authorization_code', 'refresh_token' ], 'response_types' => [ 'code' ], 'scope' => $scopes ]; $config = []; if (defined('BEHAT_SITE_RUNNING')) { $config['verify'] = false; } $client = new http_client($config); $request = new Request( 'POST', $url, [ 'Content-type' => 'application/json', 'Accept' => 'application/json', ], json_encode($request) ); try { $response = $client->send($request); $responsebody = $response->getBody()->getContents(); $decodedbody = json_decode($responsebody, true); if (is_null($decodedbody)) { throw new \moodle_exception('Error: ' . __METHOD__ . ': Failed to decode response body. Invalid JSON.'); } $issuer->set('clientid', $decodedbody['client_id']); $issuer->set('clientsecret', $decodedbody['client_secret']); $issuer->update(); } catch (\Exception $e) { $msg = "Could not self-register {$issuer->get('name')}. Wrong URL or JSON data [URL: $url]"; throw new \moodle_exception($msg); } } } } } service/facebook.php000064400000007251152267211100010470 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; use core\oauth2\endpoint; use core\oauth2\user_field_mapping; use core\oauth2\discovery\openidconnect; /** * Class for Facebook oAuth service, with the specific methods related to it. * * @package core * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class facebook extends openidconnect implements issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer The issuer initialised with proper default values. */ public static function init(): issuer { $record = (object) [ 'name' => 'Facebook', 'image' => 'https://facebookbrand.com/wp-content/uploads/2016/05/flogo_rgb_hex-brc-site-250.png', 'baseurl' => '', 'loginscopes' => 'public_profile email', 'loginscopesoffline' => 'public_profile email', 'showonloginpage' => issuer::EVERYWHERE, 'servicetype' => 'facebook', ]; $issuer = new issuer(0, $record); return $issuer; } /** * Create endpoints for this issuer. * * @param issuer $issuer Issuer the endpoints should be created for. * @return issuer */ public static function create_endpoints(issuer $issuer): issuer { // The Facebook API version. $apiversion = '2.12'; // The Graph API URL. $graphurl = 'https://graph.facebook.com/v' . $apiversion; // User information fields that we want to fetch. $infofields = [ 'id', 'first_name', 'last_name', 'picture.type(large)', 'name', 'email', ]; $endpoints = [ 'authorization_endpoint' => sprintf('https://www.facebook.com/v%s/dialog/oauth', $apiversion), 'token_endpoint' => $graphurl . '/oauth/access_token', 'userinfo_endpoint' => $graphurl . '/me?fields=' . implode(',', $infofields) ]; foreach ($endpoints as $name => $url) { $record = (object) [ 'issuerid' => $issuer->get('id'), 'name' => $name, 'url' => $url ]; $endpoint = new endpoint(0, $record); $endpoint->create(); } // Create the field mappings. $mapping = [ 'name' => 'alternatename', 'last_name' => 'lastname', 'email' => 'email', 'first_name' => 'firstname', 'picture-data-url' => 'picture', ]; foreach ($mapping as $external => $internal) { $record = (object) [ 'issuerid' => $issuer->get('id'), 'externalfield' => $external, 'internalfield' => $internal ]; $userfieldmapping = new user_field_mapping(0, $record); $userfieldmapping->create(); } return $issuer; } } service/custom.php000064400000002574152267211100010234 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; use core\oauth2\discovery\openidconnect; /** * Class for Custom services, with the specific methods related to it. * * @package core * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class custom extends openidconnect implements issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer|null The issuer initialised with proper default values. */ public static function init(): ?issuer { // Custom service doesn't require any particular initialization. return null; } } service/microsoft.php000064400000005553152267211100010727 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; use core\oauth2\user_field_mapping; use core\oauth2\discovery\openidconnect; /** * Class for Microsoft oAuth service, with the specific methods related to it. * * @package core * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class microsoft extends openidconnect implements issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer The issuer initialised with proper default values. */ public static function init(): issuer { $record = (object) [ 'name' => 'Microsoft', 'image' => 'https://www.microsoft.com/favicon.ico', 'baseurl' => 'https://login.microsoftonline.com/common/v2.0', 'loginscopes' => 'openid profile email user.read', 'loginscopesoffline' => 'openid profile email user.read offline_access', 'showonloginpage' => issuer::EVERYWHERE, 'servicetype' => 'microsoft', ]; $issuer = new issuer(0, $record); return $issuer; } #[\Override] protected static function create_field_mappings(issuer $issuer): void { // Remove existing user field mapping. foreach (user_field_mapping::get_records(['issuerid' => $issuer->get('id')]) as $userfieldmapping) { $userfieldmapping->delete(); } // Create the field mappings. $mapping = [ 'sub' => 'idnumber', 'given_name' => 'firstname', 'family_name' => 'lastname', 'email' => 'email', 'displayName' => 'alternatename', 'officeLocation' => 'address', 'mobilePhone' => 'phone1', 'locale' => 'lang', ]; foreach ($mapping as $external => $internal) { $record = (object) [ 'issuerid' => $issuer->get('id'), 'externalfield' => $external, 'internalfield' => $internal, ]; $userfieldmapping = new user_field_mapping(0, $record); $userfieldmapping->create(); } } } service/google.php000064400000003317152267211100010172 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; use core\oauth2\discovery\openidconnect; /** * Class for Google oAuth service, with the specific methods related to it. * * @package core * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class google extends openidconnect implements issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer|null The issuer initialised with proper default values. */ public static function init(): ?issuer { $record = (object) [ 'name' => 'Google', 'image' => 'https://accounts.google.com/favicon.ico', 'baseurl' => 'https://accounts.google.com/', 'loginparamsoffline' => 'access_type=offline&prompt=consent', 'showonloginpage' => issuer::EVERYWHERE, 'servicetype' => 'google', ]; $issuer = new issuer(0, $record); return $issuer; } } service/nextcloud.php000064400000006660152267211100010727 0ustar00. namespace core\oauth2\service; use core\oauth2\issuer; use core\oauth2\endpoint; use core\oauth2\user_field_mapping; use core\oauth2\discovery\openidconnect; /** * Class for Nextcloud oAuth service, with the specific methods related to it. * * @package core * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class nextcloud extends openidconnect implements issuer_interface { /** * Build an OAuth2 issuer, with all the default values for this service. * * @return issuer The issuer initialised with proper default values. */ public static function init(): issuer { $record = (object) [ 'name' => 'Nextcloud', 'image' => 'https://nextcloud.com/wp-content/uploads/2022/03/favicon.png', 'basicauth' => 1, 'servicetype' => 'nextcloud', ]; $issuer = new issuer(0, $record); return $issuer; } /** * Create endpoints for this issuer. * * @param issuer $issuer Issuer the endpoints should be created for. * @return issuer */ public static function create_endpoints(issuer $issuer): issuer { // Nextcloud has a custom baseurl. Thus, the creation of endpoints has to be done later. $baseurl = $issuer->get('baseurl'); // Add trailing slash to baseurl, if needed. if (substr($baseurl, -1) !== '/') { $baseurl .= '/'; } $endpoints = [ // Baseurl will be prepended later. 'authorization_endpoint' => 'index.php/apps/oauth2/authorize', 'token_endpoint' => 'index.php/apps/oauth2/api/v1/token', 'userinfo_endpoint' => 'ocs/v2.php/cloud/user?format=json', 'webdav_endpoint' => 'remote.php/webdav/', 'ocs_endpoint' => 'ocs/v1.php/apps/files_sharing/api/v1/shares', ]; foreach ($endpoints as $name => $url) { $record = (object) [ 'issuerid' => $issuer->get('id'), 'name' => $name, 'url' => $baseurl . $url, ]; $endpoint = new \core\oauth2\endpoint(0, $record); $endpoint->create(); } // Create the field mappings. $mapping = [ 'ocs-data-email' => 'email', 'ocs-data-id' => 'username', ]; foreach ($mapping as $external => $internal) { $record = (object) [ 'issuerid' => $issuer->get('id'), 'externalfield' => $external, 'internalfield' => $internal ]; $userfieldmapping = new \core\oauth2\user_field_mapping(0, $record); $userfieldmapping->create(); } return $issuer; } } issuer.php000064400000021157152267211100006572 0ustar00. /** * Class for loading/storing issuers from the DB. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; defined('MOODLE_INTERNAL') || die(); use core\persistent; use lang_string; /** * Class for loading/storing issuer from the DB * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class issuer extends persistent { /** @var int Issuer is displayed on both login page and in the services lists */ const EVERYWHERE = 1; /** @var int Issuer is displayed on the login page only */ const LOGINONLY = 2; /** @var int Issuer is used for sending email using SMTP with XOAUTH2 */ const SMTPWITHXOAUTH2 = 3; /** @var int Issuer is displayed only in the services lists and can not be used for login */ const SERVICEONLY = 0; const TABLE = 'oauth2_issuer'; /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return array( 'name' => array( 'type' => PARAM_TEXT ), 'image' => array( 'type' => PARAM_URL, 'null' => NULL_ALLOWED, 'default' => null ), 'clientid' => array( 'type' => PARAM_RAW_TRIMMED, 'default' => '' ), 'clientsecret' => array( 'type' => PARAM_RAW_TRIMMED, 'default' => '' ), 'baseurl' => array( 'type' => PARAM_URL, 'default' => '' ), 'enabled' => array( 'type' => PARAM_BOOL, 'default' => true ), 'showonloginpage' => array( 'type' => PARAM_INT, 'default' => self::SERVICEONLY, ), 'basicauth' => array( 'type' => PARAM_BOOL, 'default' => false ), 'scopessupported' => array( 'type' => PARAM_RAW, 'null' => NULL_ALLOWED, 'default' => null ), 'loginscopes' => array( 'type' => PARAM_RAW, 'default' => 'openid profile email' ), 'loginscopesoffline' => array( 'type' => PARAM_RAW, 'default' => 'openid profile email' ), 'loginparams' => array( 'type' => PARAM_RAW, 'default' => '' ), 'loginparamsoffline' => array( 'type' => PARAM_RAW, 'default' => '' ), 'alloweddomains' => array( 'type' => PARAM_RAW, 'default' => '' ), 'sortorder' => array( 'type' => PARAM_INT, 'default' => 0, ), 'requireconfirmation' => array( 'type' => PARAM_BOOL, 'default' => true ), 'servicetype' => array( 'type' => PARAM_ALPHANUM, 'null' => NULL_ALLOWED, 'default' => null, ), 'loginpagename' => array( 'type' => PARAM_TEXT, 'null' => NULL_ALLOWED, 'default' => null, ), 'systememail' => [ 'type' => PARAM_EMAIL, 'null' => NULL_ALLOWED, 'default' => null, ], ); } /** * Hook to execute before validate. * * @return void */ protected function before_validate() { if (($this->get('id') && $this->get('sortorder') === null) || !$this->get('id')) { $this->set('sortorder', $this->count_records()); } } /** * Helper the get a named service endpoint. * @param string $type * @return string|false */ public function get_endpoint_url($type) { $endpoint = endpoint::get_record([ 'issuerid' => $this->get('id'), 'name' => $type . '_endpoint' ]); if ($endpoint) { return $endpoint->get('url'); } return false; } /** * Perform matching against the list of allowed login domains for this issuer. * * @param string $email The email to check. * @return boolean */ public function is_valid_login_domain($email) { if (empty($this->get('alloweddomains'))) { return true; } $validdomains = explode(',', $this->get('alloweddomains')); $parts = explode('@', $email, 2); $emaildomain = ''; if (count($parts) > 1) { $emaildomain = $parts[1]; } return \core\ip_utils::is_domain_in_allowed_list($emaildomain, $validdomains); } /** * Does this OAuth service support user authentication? * @return boolean */ public function is_authentication_supported() { debugging('Method is_authentication_supported() is deprecated, please use is_available_for_login()', DEBUG_DEVELOPER); return (!empty($this->get_endpoint_url('userinfo'))); } /** * Is this issue fully configured and enabled and can be used for login/signup * * @return bool * @throws \coding_exception */ public function is_available_for_login(): bool { return $this->get('id') && $this->is_configured() && $this->get('showonloginpage') != self::SERVICEONLY && $this->get('showonloginpage') != self::SMTPWITHXOAUTH2 && $this->get('enabled') && !empty($this->get_endpoint_url('userinfo')); } /** * Return true if this issuer looks like it has been configured. * * @return boolean */ public function is_configured() { return (!empty($this->get('clientid')) && !empty($this->get('clientsecret'))); } /** * Do we have a refresh token for a system account? * @return boolean */ public function is_system_account_connected() { if (!$this->is_configured()) { return false; } $sys = system_account::get_record(['issuerid' => $this->get('id')]); if (empty($sys) || empty($sys->get('refreshtoken'))) { return false; } $scopes = api::get_system_scopes_for_issuer($this); $grantedscopes = $sys->get('grantedscopes'); $scopes = explode(' ', $scopes); foreach ($scopes as $scope) { if (!empty($scope)) { if (strpos(' ' . $grantedscopes . ' ', ' ' . $scope . ' ') === false) { // We have not been granted all the scopes that are required. return false; } } } return true; } /** * Custom validator for end point URLs. * Because we send Bearer tokens we must ensure SSL. * * @param string $value The value to check. * @return lang_string|boolean */ protected function validate_baseurl($value) { global $CFG; include_once($CFG->dirroot . '/lib/validateurlsyntax.php'); if (!empty($value) && !validateUrlSyntax($value, 'S+')) { return new lang_string('sslonlyaccess', 'error'); } return true; } /** * Display name for the issuers used on the login page * * @return string */ public function get_display_name(): string { return $this->get('loginpagename') ? $this->get('loginpagename') : $this->get('name'); } /** * Get the system email address for this issuer. * * @return string|null The system email address or null if not set. */ public function get_system_email(): ?string { return $this->get('systememail') ? $this->get('systememail') : null; } } user_field_mapping.php000064400000007223152267211100011112 0ustar00. /** * Class for loading/storing oauth2 endpoints from the DB. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; defined('MOODLE_INTERNAL') || die(); use core\persistent; use lang_string; /** * Class for loading/storing oauth2 user field mappings from the DB * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user_field_mapping extends persistent { const TABLE = 'oauth2_user_field_mapping'; /** * Return the list of valid internal user fields. * * @return array */ private static function get_user_fields() { global $CFG; require_once($CFG->dirroot . '/user/profile/lib.php'); return array_merge(\core_user::AUTHSYNCFIELDS, ['picture', 'username'], get_profile_field_names()); } /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return array( 'issuerid' => array( 'type' => PARAM_INT ), 'externalfield' => array( 'type' => PARAM_RAW_TRIMMED, ), 'internalfield' => array( 'type' => PARAM_ALPHANUMEXT, 'choices' => self::get_user_fields() ) ); } /** * Return the list of internal fields * in a format they can be used for choices in a select menu * @return array */ public function get_internalfield_list() { $userfields = array_merge(\core_user::AUTHSYNCFIELDS, ['picture', 'username']); $internalfields = array_combine($userfields, $userfields); return array_merge(['' => $internalfields], get_profile_field_list()); } /** * Return the list of internal fields with flat array * * Profile fields element has its array based on profile category. * These elements need to be turned flat to make it easier to read. * * @return array */ public function get_internalfields() { $userfieldlist = $this->get_internalfield_list(); $userfields = []; array_walk_recursive($userfieldlist, function($value, $key) use (&$userfields) { $userfields[] = $key; } ); return $userfields; } /** * Ensures that no HTML is saved to externalfield field * but preserves all special characters that can be a part of the claim * @return boolean true if validation is successful, string error if externalfield is not validated */ protected function validate_externalfield($value){ // This parameter type is set to PARAM_RAW_TRIMMED and HTML check is done here. if (clean_param($value, PARAM_NOTAGS) !== $value){ return new lang_string('userfieldexternalfield_error', 'tool_oauth2'); } return true; } } client/linkedin.php000064400000003576152267211100010340 0ustar00. namespace core\oauth2\client; use core\oauth2\client; /** * Class linkedin - Custom client handler to fetch data from linkedin * * Custom oauth2 client for linkedin as it doesn't support OIDC and has a different way to get * key information for users - firstname, lastname, email. * * @copyright 2021 Peter Dias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @package core */ class linkedin extends client { /** * Override to handle LinkedIn's non-spec-compliant 'locale' field, which isn't a string (e.g. 'en-US') but an object. * * @return array|false */ public function get_userinfo() { $rawuserinfo = $this->get_raw_userinfo(); if ($rawuserinfo === false) { return false; } if (!empty($rawuserinfo->locale) && is_object($rawuserinfo->locale)) { if (!empty($rawuserinfo->locale->language) && !empty($rawuserinfo->locale->country)) { $rawuserinfo->locale = "{$rawuserinfo->locale->language}-{$rawuserinfo->locale->country}"; } else { unset($rawuserinfo->locale); } } return $this->map_userinfo_to_fields($rawuserinfo); } } client/microsoft.php000064400000006111152267211100010534 0ustar00. namespace core\oauth2\client; use core\oauth2\client; use stdClass; /** * Custom oauth2 client for Microsoft to handle specific requirements. * * @package core * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class microsoft extends client { #[\Override] public function get_additional_upgrade_token_parameters(): array { $issuer = $this->get_issuer(); if ($issuer->get('showonloginpage') == $issuer::SMTPWITHXOAUTH2) { // We are using this issuer for SMTP with XOAUTH2. // We need to add the SMTP scope to the token request. return [ 'scope' => 'https://outlook.office.com/SMTP.Send', ]; } return parent::get_additional_upgrade_token_parameters(); } #[\Override] protected function map_userinfo_to_fields(stdClass $userinfo): array { // Microsoft returns different field names depending on account type: // - Work/School accounts: OpenID Connect standard (given_name, family_name) // - Personal accounts: Non-standard lowercase (givenname, familyname) // We need to check both formats to support all Microsoft account types. // // Additionally, we provide bidirectional fallback to handle sites that have not yet // run the database upgrade to update field mappings from the old format to the new format. // Add fallback mappings for personal accounts if the standard fields are not present. if (empty($userinfo->given_name) && !empty($userinfo->givenname)) { $userinfo->given_name = $userinfo->givenname; } if (empty($userinfo->family_name) && !empty($userinfo->familyname)) { $userinfo->family_name = $userinfo->familyname; } // Add reverse fallback for sites with old database mappings (givenname/familyname). // This ensures work/school accounts work even before the database upgrade runs. if (empty($userinfo->givenname) && !empty($userinfo->given_name)) { $userinfo->givenname = $userinfo->given_name; } if (empty($userinfo->familyname) && !empty($userinfo->family_name)) { $userinfo->familyname = $userinfo->family_name; } // Call parent to handle the standard mapping. return parent::map_userinfo_to_fields($userinfo); } } rest.php000064400000011007152267211100006226 0ustar00. /** * Rest API base class mapping rest api methods to endpoints with http methods, args and post body. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; use curl; use coding_exception; use stdClass; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/filelib.php'); /** * Rest API base class mapping rest api methods to endpoints with http methods, args and post body. * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class rest { /** @var curl $curl */ protected $curl; /** * Constructor. * * @param curl $curl */ public function __construct(curl $curl) { $this->curl = $curl; } /** * Abstract function to define the functions of the rest API. * * @return array Example: * [ 'listFiles' => [ 'method' => 'get', 'args' => [ 'folder' => PARAM_STRING ], 'response' => 'json' ] ] */ abstract public function get_api_functions(); /** * Call a function from the Api with a set of arguments and optional data. * * @param string $functionname * @param array $functionargs * @param string $rawpost Optional param to include in the body of a post. * @param string $contenttype The MIME type for the request's Content-Type header. * @return string|stdClass */ public function call($functionname, $functionargs, $rawpost = false, $contenttype = false) { $functions = $this->get_api_functions(); $supportedmethods = [ 'get', 'put', 'post', 'patch', 'head', 'delete' ]; if (empty($functions[$functionname])) { throw new coding_exception('unsupported api functionname: ' . $functionname); } $method = $functions[$functionname]['method']; $endpoint = $functions[$functionname]['endpoint']; $responsetype = $functions[$functionname]['response']; if (!in_array($method, $supportedmethods)) { throw new coding_exception('unsupported api method: ' . $method); } $args = $functions[$functionname]['args']; $callargs = []; foreach ($args as $argname => $argtype) { if (isset($functionargs[$argname])) { $callargs[$argname] = clean_param($functionargs[$argname], $argtype); } } // Allow params in the URL path like /me/{parent}/children. foreach ($callargs as $argname => $value) { $newendpoint = str_replace('{' . $argname . '}', $value, $endpoint); if ($newendpoint != $endpoint) { $endpoint = $newendpoint; unset($callargs[$argname]); } } if ($rawpost !== false) { $queryparams = $this->curl->build_post_data($callargs); if (!empty($queryparams)) { $endpoint .= '?' . $queryparams; } $callargs = $rawpost; } if (empty($contenttype)) { $this->curl->setHeader('Content-type: application/json'); } else { $this->curl->setHeader('Content-type: ' . $contenttype); } $response = $this->curl->$method($endpoint, $callargs); if ($this->curl->errno == 0) { if ($responsetype == 'json') { $json = json_decode($response); if (!empty($json->error)) { throw new rest_exception($json->error->code . ': ' . $json->error->message); } return $json; } else if ($responsetype == 'headers') { $response = $this->curl->get_raw_response(); } return $response; } else { throw new rest_exception($this->curl->error, $this->curl->errno); } } } discovery/imsbadgeconnect.php000064400000017217152267211100012416 0ustar00. namespace core\oauth2\discovery; use curl; use stdClass; use moodle_exception; use core\oauth2\issuer; use core\oauth2\endpoint; /** * Class for IMS Open Badge Connect API (aka OBv2.1) discovery definition. * * @package core * @since Moodle 3.11 * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class imsbadgeconnect extends base_definition { /** * Get the URL for the discovery manifest. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @return string The URL of the discovery file, containing the endpoints. */ public static function get_discovery_endpoint_url(issuer $issuer): string { $url = $issuer->get('baseurl'); if (!empty($url)) { // Add slash at the end of the base url. $url .= (substr($url, -1) == '/' ? '' : '/'); // Append the well-known file for IMS OBv2.1. $url .= '.well-known/badgeconnect.json'; } return $url; } /** * Process the discovery information and create endpoints defined with the expected format. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @param stdClass $info The discovery information, with the endpoints to process and create. * @return void */ protected static function process_configuration_json(issuer $issuer, stdClass $info): void { $info = array_pop($info->badgeConnectAPI); foreach ($info as $key => $value) { if (substr_compare($key, 'Url', - strlen('Url')) === 0 && !empty($value)) { $record = new stdClass(); $record->issuerid = $issuer->get('id'); // Convert key names from xxxxUrl to xxxx_endpoint, in order to make it compliant with the Moodle oAuth API. $record->name = strtolower(substr($key, 0, - strlen('Url'))) . '_endpoint'; $record->url = $value; $endpoint = new endpoint(0, $record); $endpoint->create(); } else if ($key == 'scopesOffered') { // Get and update supported scopes. $issuer->set('scopessupported', implode(' ', $value)); $issuer->update(); } else if ($key == 'image' && empty($issuer->get('image'))) { // Update the image with the value in the manifest file if it's valid and empty in the issuer. $url = filter_var($value, FILTER_SANITIZE_URL); // Remove multiple slashes in URL. It will fix the Badgr bug with image URL defined in their manifest. $url = preg_replace('/([^:])(\/{2,})/', '$1/', $url); if (filter_var($url, FILTER_VALIDATE_URL) !== false) { $issuer->set('image', $url); $issuer->update(); } } else if ($key == 'apiBase') { (new endpoint(0, (object) [ 'issuerid' => $issuer->get('id'), 'name' => $key, 'url' => $value, ]))->create(); } else if ($key == 'name') { // Get and update issuer name. $issuer->set('name', $value); $issuer->update(); } } } /** * Process how to map user field information. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @return void */ protected static function create_field_mappings(issuer $issuer): void { // In that case, there are no user fields to map. } /** * Self-register the issuer if the 'registration' endpoint exists and client id and secret aren't defined. * * @param issuer $issuer The OAuth issuer to register. * @return void */ protected static function register(issuer $issuer): void { global $CFG, $SITE; $clientid = $issuer->get('clientid'); $clientsecret = $issuer->get('clientsecret'); // Registration request for getting client id and secret will be done only they are empty in the issuer. // For now this can't be run from PHPUNIT (because IMS testing platform needs real URLs). In the future, this // request can be moved to the moodle-exttests repository. if (empty($clientid) && empty($clientsecret) && (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST)) { $url = $issuer->get_endpoint_url('registration'); if ($url) { $scopes = str_replace("\r", " ", $issuer->get('scopessupported')); // Add slash at the end of the site URL. $hosturl = $CFG->wwwroot; $hosturl .= (substr($CFG->wwwroot, -1) == '/' ? '' : '/'); // Create the registration request following the format defined in the IMS OBv2.1 specification. $request = [ 'client_name' => $SITE->fullname, 'client_uri' => $hosturl, 'logo_uri' => $hosturl . 'pix/moodlelogo.png', 'tos_uri' => $hosturl, 'policy_uri' => $hosturl, 'software_id' => 'moodle', 'software_version' => $CFG->version, 'redirect_uris' => [ $hosturl . 'admin/oauth2callback.php' ], 'token_endpoint_auth_method' => 'client_secret_basic', 'grant_types' => [ 'authorization_code', 'refresh_token' ], 'response_types' => [ 'code' ], 'scope' => $scopes ]; $jsonrequest = json_encode($request); $curl = new curl(); $curl->setHeader(['Content-type: application/json']); $curl->setHeader(['Accept: application/json']); // Send the registration request. if (!$jsonresponse = $curl->post($url, $jsonrequest)) { $msg = 'Could not self-register identity issuer: ' . $issuer->get('name') . ". Wrong URL or JSON data [URL: $url]"; throw new moodle_exception($msg); } // Process the response and update client id and secret if they are valid. $response = json_decode($jsonresponse); if (property_exists($response, 'client_id')) { $issuer->set('clientid', $response->client_id); $issuer->set('clientsecret', $response->client_secret); $issuer->update(); } else { $msg = 'Could not self-register identity issuer: ' . $issuer->get('name') . '. Invalid response ' . $jsonresponse; throw new moodle_exception($msg); } } } } } discovery/auth_server_config_reader.php000064400000010630152267211100014457 0ustar00. namespace core\oauth2\discovery; use core\http_client; use GuzzleHttp\Exception\ClientException; /** * Simple reader class, allowing OAuth 2 Authorization Server Metadata to be read from an auth server's well-known. * * {@link https://www.rfc-editor.org/rfc/rfc8414} * * @package core * @copyright 2023 Jake Dallimore * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class auth_server_config_reader { /** @var \stdClass the config object read from the discovery document. */ protected \stdClass $metadata; /** @var \moodle_url the base URL for the auth server which was last used during a read.*/ protected \moodle_url $issuerurl; /** * Constructor. * * @param http_client $httpclient an http client instance. * @param string $wellknownsuffix the well-known suffix, defaulting to 'oauth-authorization-server'. */ public function __construct(protected http_client $httpclient, protected string $wellknownsuffix = 'oauth-authorization-server') { } /** * Read the metadata from the remote host. * * @param \moodle_url $issuerurl the auth server issuer URL. * @return \stdClass the configuration data object. * @throws ClientException|\GuzzleHttp\Exception\GuzzleException if the http client experiences any problems. */ public function read_configuration(\moodle_url $issuerurl): \stdClass { $this->issuerurl = $issuerurl; $this->validate_uri(); $url = $this->get_configuration_url()->out(false); $response = $this->httpclient->request('GET', $url); $this->metadata = json_decode($response->getBody()); return $this->metadata; } /** * Make sure the base URI is suitable for use in discovery. * * @return void * @throws \moodle_exception if the URI fails validation. */ protected function validate_uri() { if (!empty($this->issuerurl->get_query_string())) { throw new \moodle_exception('Error: '.__METHOD__.': Auth server base URL cannot contain a query component.'); } if (strtolower($this->issuerurl->get_scheme()) !== 'https') { throw new \moodle_exception('Error: '.__METHOD__.': Auth server base URL must use HTTPS scheme.'); } // This catches URL fragments. Since a query string is ruled out above, out_omit_querystring(false) returns only fragments. if ($this->issuerurl->out_omit_querystring() != $this->issuerurl->out(false)) { throw new \moodle_exception('Error: '.__METHOD__.': Auth server base URL must not contain fragments.'); } } /** * Get the Auth server metadata URL. * * Per {@link https://www.rfc-editor.org/rfc/rfc8414#section-3}, if the issuer URL contains a path component, * the well known suffix is added between the host and path components. * * @return \moodle_url the full URL to the auth server metadata. */ protected function get_configuration_url(): \moodle_url { $path = $this->issuerurl->get_path(); if (!empty($path) && $path !== '/') { // Insert the well known suffix between the host and path components. $port = $this->issuerurl->get_port() ? ':'.$this->issuerurl->get_port() : ''; $uri = $this->issuerurl->get_scheme() . "://" . $this->issuerurl->get_host() . $port ."/". ".well-known/" . $this->wellknownsuffix . $path; } else { // No path, just append the well known suffix. $uri = $this->issuerurl->out(false); $uri .= (substr($uri, -1) == '/' ? '' : '/'); $uri .= ".well-known/$this->wellknownsuffix"; } return new \moodle_url($uri); } } discovery/base_definition.php000064400000013412152267211100012404 0ustar00. namespace core\oauth2\discovery; use curl; use stdClass; use moodle_exception; use core\oauth2\issuer; use core\oauth2\endpoint; /** * Class for provider discovery definition, to allow services easily discover and process information. * This abstract class is called from core\oauth2\api when discovery points need to be updated. * * @package core * @since Moodle 3.11 * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class base_definition { /** * Get the URL for the discovery manifest. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @return string The URL of the discovery file, containing the endpoints. */ abstract public static function get_discovery_endpoint_url(issuer $issuer): string; /** * Process the discovery information and create endpoints defined with the expected format. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @param stdClass $info The discovery information, with the endpoints to process and create. * @return void */ abstract protected static function process_configuration_json(issuer $issuer, stdClass $info): void; /** * Process how to map user field information. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @return void */ abstract protected static function create_field_mappings(issuer $issuer): void; /** * Self-register the issuer if the 'registration' endpoint exists and client id and secret aren't defined. * * @param issuer $issuer The OAuth issuer to register. * @return void */ abstract protected static function register(issuer $issuer): void; /** * Create endpoints for this issuer. * * @param issuer $issuer Issuer the endpoints should be created for. * @return issuer */ public static function create_endpoints(issuer $issuer): issuer { static::discover_endpoints($issuer); return $issuer; } /** * If the discovery endpoint exists for this issuer, try and determine the list of valid endpoints. * * @param issuer $issuer * @return int The number of discovered services. */ public static function discover_endpoints($issuer): int { // Early return if baseurl is empty. if (empty($issuer->get('baseurl'))) { return 0; } // Get the discovery URL and check if it has changed. $creatediscoveryendpoint = false; $url = $issuer->get_endpoint_url('discovery'); $providerurl = static::get_discovery_endpoint_url($issuer); if (!$url || $url != $providerurl) { $url = $providerurl; $creatediscoveryendpoint = true; } // Remove the existing endpoints before starting discovery. foreach (endpoint::get_records(['issuerid' => $issuer->get('id')]) as $endpoint) { // Discovery endpoint will be removed only if it will be created later, once we confirm it's working as expected. if ($creatediscoveryendpoint || $endpoint->get('name') != 'discovery_endpoint') { $endpoint->delete(); } } // Early return if discovery URL is empty. if (empty($url)) { return 0; } $curl = new curl(); if (!$json = $curl->get($url)) { $msg = 'Could not discover end points for identity issuer: ' . $issuer->get('name') . " [URL: $url]"; throw new moodle_exception($msg); } if ($msg = $curl->error) { throw new moodle_exception('Could not discover service endpoints: ' . $msg); } $info = json_decode($json); if (empty($info)) { $msg = 'Could not discover end points for identity issuer: ' . $issuer->get('name') . " [URL: $url]"; throw new moodle_exception($msg); } if ($creatediscoveryendpoint) { // Create the discovery endpoint (because it didn't exist and the URL exists and is returning some valid JSON content). static::create_discovery_endpoint($issuer, $url); } static::process_configuration_json($issuer, $info); static::create_field_mappings($issuer); static::register($issuer); return endpoint::count_records(['issuerid' => $issuer->get('id')]); } /** * Helper method to create discovery endpoint. * * @param issuer $issuer Issuer the endpoints should be created for. * @param string $url Discovery endpoint URL. * @return endpoint The endpoint created. * * @throws \core\invalid_persistent_exception */ protected static function create_discovery_endpoint(issuer $issuer, string $url): endpoint { $record = (object) [ 'issuerid' => $issuer->get('id'), 'name' => 'discovery_endpoint', 'url' => $url, ]; $endpoint = new endpoint(0, $record); $endpoint->create(); return $endpoint; } } discovery/openidconnect.php000064400000010404152267211100012110 0ustar00. namespace core\oauth2\discovery; use stdClass; use core\oauth2\issuer; use core\oauth2\endpoint; use core\oauth2\user_field_mapping; /** * Class for Open ID Connect discovery definition. * * @package core * @since Moodle 3.11 * @copyright 2021 Sara Arjona (sara@moodle.com) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class openidconnect extends base_definition { /** * Get the URL for the discovery manifest. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @return string The URL of the discovery file, containing the endpoints. */ public static function get_discovery_endpoint_url(issuer $issuer): string { $url = $issuer->get('baseurl'); if (!empty($url)) { // Add slash at the end of the base url. $url .= (substr($url, -1) == '/' ? '' : '/'); // Append the well-known file for OIDC. $url .= '.well-known/openid-configuration'; } return $url; } /** * Process the discovery information and create endpoints defined with the expected format. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @param stdClass $info The discovery information, with the endpoints to process and create. * @return void */ protected static function process_configuration_json(issuer $issuer, stdClass $info): void { foreach ($info as $key => $value) { if (substr_compare($key, '_endpoint', - strlen('_endpoint')) === 0) { $record = new stdClass(); $record->issuerid = $issuer->get('id'); $record->name = $key; $record->url = $value; $endpoint = new endpoint(0, $record); $endpoint->create(); } if ($key == 'scopes_supported') { $issuer->set('scopessupported', implode(' ', $value)); $issuer->update(); } } } /** * Process how to map user field information. * * @param issuer $issuer The OAuth issuer the endpoints should be discovered for. * @return void */ protected static function create_field_mappings(issuer $issuer): void { // Remove existing user field mapping. foreach (user_field_mapping::get_records(['issuerid' => $issuer->get('id')]) as $userfieldmapping) { $userfieldmapping->delete(); } // Create the default user field mapping list. $mapping = [ 'given_name' => 'firstname', 'middle_name' => 'middlename', 'family_name' => 'lastname', 'email' => 'email', 'nickname' => 'alternatename', 'picture' => 'picture', 'address' => 'address', 'phone' => 'phone1', 'locale' => 'lang', ]; foreach ($mapping as $external => $internal) { $record = (object) [ 'issuerid' => $issuer->get('id'), 'externalfield' => $external, 'internalfield' => $internal ]; $userfieldmapping = new user_field_mapping(0, $record); $userfieldmapping->create(); } } /** * Self-register the issuer if the 'registration' endpoint exists and client id and secret aren't defined. * * @param issuer $issuer The OAuth issuer to register. * @return void */ protected static function register(issuer $issuer): void { // Registration not supported (at least for now). } } rest_exception.php000064400000002314152267211100010305 0ustar00. /** * Rest Exception class containing error code and message. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; use Exception; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/filelib.php'); /** * Rest Exception class containing error code and message. * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class rest_exception extends Exception { } endpoint.php000064400000004047152267211100007077 0ustar00. /** * Class for loading/storing oauth2 endpoints from the DB. * * @package core * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; defined('MOODLE_INTERNAL') || die(); use core\persistent; use lang_string; /** * Class for loading/storing oauth2 endpoints from the DB * * @copyright 2017 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class endpoint extends persistent { const TABLE = 'oauth2_endpoint'; /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return array( 'issuerid' => array( 'type' => PARAM_INT ), 'name' => array( 'type' => PARAM_ALPHANUMEXT, ), 'url' => array( 'type' => PARAM_URL, ) ); } /** * Custom validator for end point URLs. * Because we send Bearer tokens we must ensure SSL. * * @param string $value The value to check. * @return lang_string|boolean */ protected function validate_url($value) { if (strpos($value, 'https://') !== 0) { return new lang_string('sslonlyaccess', 'error'); } return true; } } access_token.php000064400000005351152267211100007717 0ustar00. /** * Loads/stores oauth2 access tokens in DB for system accounts in order to use a single token across multiple sessions. * * @package core * @copyright 2018 Jan Dageförde * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core\oauth2; defined('MOODLE_INTERNAL') || die(); use core\persistent; /** * Loads/stores oauth2 access tokens in DB for system accounts in order to use a single token across multiple sessions. * * When a system user is authenticated via OAuth, we need to use a single access token across user sessions, * because we want to avoid using multiple tokens at the same time for a single remote user. Reasons are that, * first, redeeming the refresh token for an access token requires an additional request, and second, there is * no guarantee that redeeming the refresh token doesn't invalidate *all* corresponding previous access tokes. * As a result, we would need to either continuously request lots and lots of new access tokens, or persist the * access token in the DB where it can be used from all sessions. Let's do the latter! * * @copyright 2018 Jan Dageförde * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class access_token extends persistent { /** The table name. */ const TABLE = 'oauth2_access_token'; /** * Return the definition of the properties of this model. * * @return array */ protected static function define_properties() { return array( // Issuer id instead of the system account id because, at the time of storing/loading a token we may not // know the system account id. 'issuerid' => array( 'type' => PARAM_INT ), 'token' => array( 'type' => PARAM_RAW, ), 'expires' => array( 'type' => PARAM_INT, ), 'scope' => array( 'type' => PARAM_RAW, ), ); } }