From 1db743ea9a657961c30ccf1111787d659efc31fc Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Tue, 20 May 2025 08:34:56 +0700 Subject: [PATCH 1/2] MDL-80380 admin: Support Microsoft XOAUTH2 for outgoing email --- admin/tool/oauth2/classes/form/issuer.php | 8 ++++ admin/tool/oauth2/classes/output/renderer.php | 3 +- admin/tool/oauth2/lang/en/tool_oauth2.php | 5 ++- lib/classes/oauth2/api.php | 16 ++++++- lib/classes/oauth2/client/microsoft.php | 42 +++++++++++++++++++ lib/classes/oauth2/issuer.php | 17 ++++++++ lib/db/install.xml | 3 +- lib/db/upgrade.php | 15 +++++++ lib/oauthlib.php | 14 +++++++ lib/tests/oauth2_test.php | 6 +++ version.php | 2 +- 11 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 lib/classes/oauth2/client/microsoft.php diff --git a/admin/tool/oauth2/classes/form/issuer.php b/admin/tool/oauth2/classes/form/issuer.php index 93ab537bd48..1f79e649c33 100644 --- a/admin/tool/oauth2/classes/form/issuer.php +++ b/admin/tool/oauth2/classes/form/issuer.php @@ -129,6 +129,7 @@ class issuer extends persistent { \core\oauth2\issuer::EVERYWHERE => get_string('issueruseineverywhere', 'tool_oauth2'), \core\oauth2\issuer::LOGINONLY => get_string('issueruseinloginonly', 'tool_oauth2'), \core\oauth2\issuer::SERVICEONLY => get_string('issueruseininternalonly', 'tool_oauth2'), + \core\oauth2\issuer::SMTPWITHXOAUTH2 => get_string('issueruseinsmtpwithoauth', 'tool_oauth2'), ]; $mform->addElement('select', 'showonloginpage', get_string('issuerusein', 'tool_oauth2'), $options); $mform->addHelpButton('showonloginpage', 'issuerusein', 'tool_oauth2'); @@ -138,6 +139,13 @@ class issuer extends persistent { $mform->addRule('loginpagename', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); $mform->addHelpButton('loginpagename', 'issuerloginpagename', 'tool_oauth2'); $mform->hideIf('loginpagename', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY); + $mform->hideIf('loginpagename', 'showonloginpage', 'eq', \core\oauth2\issuer::SMTPWITHXOAUTH2); + + // Connected email for XOAUTH2. + $mform->addElement('text', 'systememail', get_string('issuersmtpsystememail', 'tool_oauth2')); + $mform->setType('systememail', PARAM_EMAIL); + $mform->addHelpButton('systememail', 'issuersmtpsystememail', 'tool_oauth2'); + $mform->hideIf('systememail', 'showonloginpage', 'ne', \core\oauth2\issuer::SMTPWITHXOAUTH2); // Login scopes. $mform->addElement('text', 'loginscopes', get_string('issuerloginscopes', 'tool_oauth2')); diff --git a/admin/tool/oauth2/classes/output/renderer.php b/admin/tool/oauth2/classes/output/renderer.php index cb79978ab10..f46d52892a6 100644 --- a/admin/tool/oauth2/classes/output/renderer.php +++ b/admin/tool/oauth2/classes/output/renderer.php @@ -86,7 +86,8 @@ class renderer extends plugin_renderer_base { $namecell->header = true; // Login issuer. - if ((int)$issuer->get('showonloginpage') == issuer::SERVICEONLY) { + if ((int)$issuer->get('showonloginpage') == issuer::SERVICEONLY || + (int)$issuer->get('showonloginpage') == issuer::SMTPWITHXOAUTH2) { $loginissuer = $this->pix_icon('no', get_string('notloginissuer', 'tool_oauth2'), 'tool_oauth2'); $logindisplayas = ''; } else { diff --git a/admin/tool/oauth2/lang/en/tool_oauth2.php b/admin/tool/oauth2/lang/en/tool_oauth2.php index cf6faf81499..6a244cf766b 100644 --- a/admin/tool/oauth2/lang/en/tool_oauth2.php +++ b/admin/tool/oauth2/lang/en/tool_oauth2.php @@ -89,13 +89,16 @@ $string['issuershowonloginpage'] = 'Show on login page'; $string['issuerrequireconfirmation_help'] = 'Require that all users verify their email address before they can log in with OAuth. This applies to newly created accounts as part of the login process, or when an existing Moodle account is connected to an OAuth login via matching email addresses.'; $string['issuerrequireconfirmation'] = 'Require email verification'; $string['issuers'] = 'Issuers'; +$string['issuersmtpsystememail'] = 'SMTP email'; +$string['issuersmtpsystememail_help'] = 'If specified, this email will be used to connect a system account for sending email via SMTP. This is required for some OAuth 2 services (e.g. Microsoft). Please check the documentation for your OAuth 2 service to see if this is required.'; $string['issuersservicesallow'] = 'Allow services'; $string['issuersservicesnotallow'] = 'Do not allow services'; $string['issuerusein'] = 'This service will be used'; -$string['issuerusein_help'] = 'OAuth 2 services can be used for internal services, on the login page, or both, if required.'; +$string['issuerusein_help'] = 'OAuth 2 services can be used for internal services, on the login page, SMTP with XOAUTH2, or both login page and internal services, if required.'; $string['issueruseineverywhere'] = 'Login page and internal services'; $string['issueruseininternalonly'] = 'Internal services only'; $string['issueruseinloginonly'] = 'Login page only'; +$string['issueruseinsmtpwithoauth'] = 'SMTP with XOAUTH2 only'; $string['issuerusedforlogin'] = 'Login'; $string['issuerusedforinternal'] = 'Internal services'; $string['linkedin_service'] = 'LinkedIn'; diff --git a/lib/classes/oauth2/api.php b/lib/classes/oauth2/api.php index f012a258551..8c7966f1a8b 100644 --- a/lib/classes/oauth2/api.php +++ b/lib/classes/oauth2/api.php @@ -641,8 +641,20 @@ class api { $record->issuerid = $issuer->get('id'); $record->refreshtoken = $refreshtoken; $record->grantedscopes = $scopes; - $record->email = isset($userinfo['email']) ? $userinfo['email'] : ''; - $record->username = $userinfo['username']; + // 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); diff --git a/lib/classes/oauth2/client/microsoft.php b/lib/classes/oauth2/client/microsoft.php new file mode 100644 index 00000000000..98e0b83cbb0 --- /dev/null +++ b/lib/classes/oauth2/client/microsoft.php @@ -0,0 +1,42 @@ +. + +namespace core\oauth2\client; + +use core\oauth2\client; + +/** + * 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(); + } +} diff --git a/lib/classes/oauth2/issuer.php b/lib/classes/oauth2/issuer.php index 89beafefb94..5d424ac98b7 100644 --- a/lib/classes/oauth2/issuer.php +++ b/lib/classes/oauth2/issuer.php @@ -40,6 +40,8 @@ class issuer extends persistent { 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; @@ -127,6 +129,11 @@ class issuer extends persistent { 'null' => NULL_ALLOWED, 'default' => null, ), + 'systememail' => [ + 'type' => PARAM_EMAIL, + 'null' => NULL_ALLOWED, + 'default' => null, + ], ); } @@ -200,6 +207,7 @@ class issuer extends persistent { 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')); } @@ -268,4 +276,13 @@ class issuer extends persistent { 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; + } } diff --git a/lib/db/install.xml b/lib/db/install.xml index 76b82d3f678..96474f3943e 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -3982,6 +3982,7 @@ + diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 0aa5825c316..310a238436a 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -1782,5 +1782,20 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2025041400.08); } + if ($oldversion < 2025041400.09) { + + // Define field systememail to be added to oauth2_issuer. + $table = new xmldb_table('oauth2_issuer'); + $field = new xmldb_field('systememail', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'loginpagename'); + + // Conditionally launch add field systememail. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2025041400.09); + } + return true; } diff --git a/lib/oauthlib.php b/lib/oauthlib.php index 9a8bc8f150d..c93815bd6de 100644 --- a/lib/oauthlib.php +++ b/lib/oauthlib.php @@ -581,6 +581,11 @@ abstract class oauth2_client extends curl { $params['client_secret'] = $this->clientsecret; } + // If we have additional parameters, add them to the request. + if ($this->get_additional_upgrade_token_parameters()) { + $params = array_merge($params, $this->get_additional_upgrade_token_parameters()); + } + // Requests can either use http GET or POST. if ($this->use_http_get()) { $response = $this->get($this->token_url(), $params); @@ -781,4 +786,13 @@ abstract class oauth2_client extends curl { protected function use_http_get() { return false; } + + /** + * An additional array of url params to pass with upgrade token request. + * + * @return array of name value pairs. + */ + public function get_additional_upgrade_token_parameters(): array { + return []; + } } diff --git a/lib/tests/oauth2_test.php b/lib/tests/oauth2_test.php index 5292b7338e3..06ca7350973 100644 --- a/lib/tests/oauth2_test.php +++ b/lib/tests/oauth2_test.php @@ -420,6 +420,12 @@ final class oauth2_test extends \advanced_testcase { $this->assertFalse($googleissuer->is_available_for_login()); + // Set showonloginpage to SMTP with XOAUTH2 only. + $googleissuer->set('showonloginpage', issuer::SMTPWITHXOAUTH2); + $googleissuer->update(); + + $this->assertFalse($googleissuer->is_available_for_login()); + // Set showonloginpage to everywhere (service and login) and disable issuer. $googleissuer->set('showonloginpage', issuer::EVERYWHERE); $googleissuer->set('enabled', 0); diff --git a/version.php b/version.php index ca332f4fcd3..ec507f0b1b1 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025041400.08; // 20250414 = branching date YYYYMMDD - do not modify! +$version = 2025041400.09; // 20250414 = branching date YYYYMMDD - do not modify! // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.0+ (Build: 20250530)'; // Human-friendly version name From d0a00b74d85ebc6274b81cb0da7adcf0c38c376a Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Wed, 21 May 2025 11:14:40 +0700 Subject: [PATCH 2/2] MDL-80380 core_auth: Add upgrade note --- .upgradenotes/MDL-80380-2025052104135989.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .upgradenotes/MDL-80380-2025052104135989.yml diff --git a/.upgradenotes/MDL-80380-2025052104135989.yml b/.upgradenotes/MDL-80380-2025052104135989.yml new file mode 100644 index 00000000000..e604009777c --- /dev/null +++ b/.upgradenotes/MDL-80380-2025052104135989.yml @@ -0,0 +1,9 @@ +issueNumber: MDL-80380 +notes: + core_auth: + - message: > + A new method called `get_additional_upgrade_token_parameters` has been + added to `oauth2_client` class. This will allow custom clients to + override this one and add their extra parameters for upgrade token + request. + type: improved