diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..89fa0cc53 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns +- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) \ No newline at end of file diff --git a/README.md b/README.md index b8baf4458..39858ea89 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -# Microsoft 365 and Microsoft Entra ID Plugins for Moodle +# Moodle Plugins for Microsoft Services +*including* **Microsoft 365** *and other Microsoft services* -## OpenID Connect Authentication Plugin. +## OpenID Connect Authentication Plugin The OpenID Connect plugin provides single-sign-on functionality using configurable identity providers. @@ -12,16 +13,17 @@ This repository is updated with stable releases. To follow active development, s 1. Unpack the plugin into /auth/oidc within your Moodle install. 2. From the Moodle Administration block, expand Site Administration and click "Notifications". -3. Follow the on-screen instuctions to install the plugin. +3. Follow the on-screen instructions to install the plugin. 4. To configure the plugin, from the Moodle Administration block, go to Site Administration > Plugins > Authentication > Manage Authentication. 5. Click the icon to enable the plugin, then visit the settings page to configure the plugin. Follow the directions below each setting. -For more documentation, visit https://docs.moodle.org/34/en/Office365 +For more documentation, visit https://docs.moodle.org/501/en/Microsoft_365 For more information including support and instructions on how to contribute, please see: https://github.com/Microsoft/o365-moodle/blob/master/README.md ## Issues and Contributing Please post issues for this plugin to: https://github.com/Microsoft/o365-moodle/issues/ + Pull requests for this plugin should be submitted against our main repository: https://github.com/Microsoft/o365-moodle ## Copyright diff --git a/SECURITY.md b/SECURITY.md index 869fdfe2b..e751608fc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,41 +1,14 @@ - + ## Security -Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). - -If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. - -## Reporting Security Issues +Microsoft takes the security of our software products and services seriously, which +includes all source code repositories in our GitHub organizations. **Please do not report security vulnerabilities through public GitHub issues.** -Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). - -If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). - -You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). - -Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: - - * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) - * Full paths of source file(s) related to the manifestation of the issue - * The location of the affected source code (tag/branch/commit or direct URL) - * Any special configuration required to reproduce the issue - * Step-by-step instructions to reproduce the issue - * Proof-of-concept or exploit code (if possible) - * Impact of the issue, including how an attacker might exploit the issue - -This information will help us triage your report more quickly. - -If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. - -## Preferred Languages - -We prefer all communications to be in English. - -## Policy - -Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). +For security reporting information, locations, contact information, and policies, +please review the latest guidance for Microsoft repositories at +[https://aka.ms/SECURITY.md](https://aka.ms/SECURITY.md). - + \ No newline at end of file diff --git a/auth.php b/auth.php index b276d7234..cc48f913c 100644 --- a/auth.php +++ b/auth.php @@ -23,6 +23,8 @@ * @copyright (C) 2014 onwards Microsoft, Inc. (http://microsoft.com/) */ +use core\url; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/authlib.php'); @@ -104,6 +106,33 @@ public function set_httpclient(\auth_oidc\httpclientinterface $httpclient) { return $this->loginflow->set_httpclient($httpclient); } + /** + * Hook for overriding behaviour of logout page. + */ + public function logoutpage_hook() { + global $redirect; + + // No need for custom logic if we don't force the redirect on login. + if (!isset($this->config->forceredirect) || !$this->config->forceredirect) { + return; + } + + // When we log out and are redirecting to the login page, add the noredirect to prevent our own redirect. + if (empty($redirect)) { + return; + } + + $redirecturl = is_string($redirect) ? new url($redirect) : $redirect; + if (!($redirecturl instanceof url)) { + return; + } + + if ($redirecturl->compare(new url('/login/index.php'), URL_MATCH_BASE)) { + $redirecturl->param('noredirect', 1); + $redirect = $redirecturl->out(false); + } + } + /** * Hook for overriding behaviour of login page. * This method is called from login/index.php page for all enabled auth plugins. @@ -123,7 +152,7 @@ public function loginpage_hook() { * @return bool If this returns true then redirect */ public function should_login_redirect() { - global $CFG, $SESSION; + global $SESSION; $oidc = optional_param('oidc', null, PARAM_BOOL); // Also support noredirect param - used by other auth plugins. @@ -148,17 +177,6 @@ public function should_login_redirect() { return false; } - // If the user is redirectred to the login page immediately after logging out, don't redirect. - $silentloginmodesetting = get_config('auth_oidc', 'silentloginmode'); - $forceredirectsetting = get_config('auth_oidc', 'forceredirect'); - $forceloginsetting = get_config('core', 'forcelogin'); - if ( - $silentloginmodesetting && $forceredirectsetting && $forceloginsetting && isset($_SERVER['HTTP_REFERER']) && - strpos($_SERVER['HTTP_REFERER'], $CFG->wwwroot) !== false - ) { - return false; - } - // Never redirect if requested so. if ($oidc === 0) { $SESSION->oidc = $oidc; @@ -196,16 +214,16 @@ public function handleredirect() { * @param bool $justremovetokens If true, just remove the stored OIDC tokens for the user, otherwise revert login methods. * @param bool $donotremovetokens If true, do not remove tokens when disconnecting. This migrates from a login account to a * "linked" account. - * @param moodle_url|null $redirect Where to redirect if successful. - * @param moodle_url|null $selfurl The page this is accessed from. Used for some redirects. + * @param url|null $redirect Where to redirect if successful. + * @param url|null $selfurl The page this is accessed from. Used for some redirects. * @param null $userid * @return mixed */ public function disconnect( $justremovetokens = false, $donotremovetokens = false, - ?\moodle_url $redirect = null, - ?\moodle_url $selfurl = null, + ?url $redirect = null, + ?url $selfurl = null, $userid = null ) { return $this->loginflow->disconnect($justremovetokens, $donotremovetokens, $redirect, $selfurl, $userid); @@ -273,7 +291,7 @@ public function user_authenticated_hook(&$user, $username, $password) { if (!empty($tokenrec)) { // If the token record username is out of sync (ie username changes), update it. if ($tokenrec->username != $user->username) { - $updatedtokenrec = new \stdClass(); + $updatedtokenrec = new stdClass(); $updatedtokenrec->id = $tokenrec->id; $updatedtokenrec->username = $user->username; $DB->update_record('auth_oidc_token', $updatedtokenrec); @@ -285,7 +303,7 @@ public function user_authenticated_hook(&$user, $username, $password) { $tokenrec = $DB->get_record('auth_oidc_token', ['username' => $username]); if (!empty($tokenrec)) { $tokenrec->userid = $user->id; - $updatedtokenrec = new \stdClass(); + $updatedtokenrec = new stdClass(); $updatedtokenrec->id = $tokenrec->id; $updatedtokenrec->userid = $user->id; $DB->update_record('auth_oidc_token', $updatedtokenrec); @@ -303,6 +321,46 @@ public function user_authenticated_hook(&$user, $username, $password) { } } + /** + * Build logout URL with appropriate IdP-specific parameters. + * + * @param string $logouturl Base logout URL from config. + * @param string $idptype IdP type (from constants). + * @param stdClass $user User object. + * @return string|null Logout URL, or null if logout should be skipped. + */ + private function build_logout_url(string $logouturl, string $idptype, stdClass $user): ?string { + global $CFG, $DB; + + $params = [ + 'post_logout_redirect_uri' => $CFG->wwwroot, + ]; + + switch ($idptype) { + case AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID: + case AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM: + if (!$logouturl) { + $logouturl = 'https://login.microsoftonline.com/organizations/oauth2/logout'; + } + $url = new url($logouturl, $params); + return $url->out(false); + + case AUTH_OIDC_IDP_TYPE_OTHER: + if (!$logouturl) { + return null; + } + $token = $DB->get_record('auth_oidc_token', ['userid' => $user->id]); + if ($token) { + $params['id_token_hint'] = $token->idtoken; + } + $url = new url($logouturl, $params); + return $url->out(false); + + default: + return null; + } + } + /** * Log out user from Microsoft 365 if single sign off integration is enabled. * @@ -326,21 +384,19 @@ public function postlogout_hook($user) { } } + // Do not redirect to logout endpoint when using loginas feature. + if (!empty($user->loginascontext)) { + $redirect = false; + } + if ($redirect) { $logouturl = get_config('auth_oidc', 'logouturi'); - if (!$logouturl) { - $logouturl = 'https://login.microsoftonline.com/organizations/oauth2/logout?post_logout_redirect_uri=' . - urlencode($CFG->wwwroot); - } else { - if ( - preg_match("/^https:\/\/login.microsoftonline.com\//", $logouturl) && - preg_match("/\/oauth2\/logout$/", $logouturl) - ) { - $logouturl .= '?post_logout_redirect_uri=' . urlencode($CFG->wwwroot); - } - } + $idptype = get_config('auth_oidc', 'idptype'); - redirect($logouturl); + $redirecturl = $this->build_logout_url($logouturl, $idptype, $user); + if ($redirecturl) { + redirect($redirecturl); + } } } diff --git a/binding_username_claim.php b/binding_username_claim.php deleted file mode 100644 index d4aa22d32..000000000 --- a/binding_username_claim.php +++ /dev/null @@ -1,115 +0,0 @@ -. - -/** - * Manage binding username claim page. - * - * @package auth_oidc - * @author Lai Wei - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @copyright (C) 2023 onwards Microsoft, Inc. (http://microsoft.com/) - */ - -use auth_oidc\form\binding_username_claim; - -require_once(dirname(__FILE__) . '/../../config.php'); -require_once($CFG->libdir . '/adminlib.php'); -require_once($CFG->dirroot . '/auth/oidc/lib.php'); - -require_login(); - -$url = new moodle_url('/auth/oidc/binding_username_claim.php'); -$PAGE->set_url($url); -$PAGE->set_context(context_system::instance()); -$PAGE->set_pagelayout('admin'); -$PAGE->set_heading(get_string('settings_page_binding_username_claim', 'auth_oidc')); -$PAGE->set_title(get_string('settings_page_binding_username_claim', 'auth_oidc')); - -admin_externalpage_setup('auth_oidc_binding_username_claim'); - -require_admin(); - -$form = new binding_username_claim(null); -$formdata = []; - -// Validate auth_oidc_binding_username_claim settings. -$predefinedbindingclaims = ['auto', 'preferred_username', 'email', 'upn', 'unique_name', 'sub', 'oid', 'samaccountname']; - -$oidcconfig = get_config('auth_oidc'); -if (!isset($oidcconfig->bindingusernameclaim)) { - // Bindingusernameclaim is not set, set default value. - $formdata['bindingusernameclaim'] = 'auto'; - $formdata['customclaimname'] = ''; - set_config('bindingusernameclaim', 'auto', 'auth_oidc'); -} else if (!$oidcconfig->bindingusernameclaim) { - $formdata['bindingusernameclaim'] = 'auto'; - $formdata['customclaimname'] = ''; -} else if (in_array($oidcconfig->bindingusernameclaim, $predefinedbindingclaims)) { - $formdata['bindingusernameclaim'] = $oidcconfig->bindingusernameclaim; - $formdata['customclaimname'] = ''; -} else { - $formdata['bindingusernameclaim'] = 'custom'; - $formdata['customclaimname'] = $oidcconfig->customclaimname ?? ''; -} - -$form->set_data($formdata); - -if ($form->is_cancelled()) { - redirect($url); -} else if ($fromform = $form->get_data()) { - $configstosave = ['bindingusernameclaim', 'customclaimname']; - - $configchanged = false; - - foreach ($configstosave as $config) { - if (isset($fromform->$config)) { - $existingsetting = $oidcconfig->$config; - if ($fromform->$config != $existingsetting) { - $configchanged = true; - set_config($config, $fromform->$config, 'auth_oidc'); - add_to_config_log($config, $existingsetting, $fromform->$config, 'auth_oidc'); - } - } - } - - if ($configchanged) { - redirect($url, get_string('binding_username_claim_updated', 'auth_oidc')); - } else { - redirect($url); - } -} - -$existingclaims = auth_oidc_get_existing_claims(); - -echo $OUTPUT->header(); - -echo $OUTPUT->heading(get_string('binding_username_claim_heading', 'auth_oidc')); -$bindingusernametoolurl = new moodle_url('/auth/oidc/change_binding_username_claim_tool.php'); -echo html_writer::tag('p', get_string('binding_username_claim_description', 'auth_oidc', $bindingusernametoolurl->out())); -if ($existingclaims) { - echo html_writer::tag( - 'p', - get_string( - 'binding_username_claim_description_existing_claims', - 'auth_oidc', - implode(' / ', $existingclaims) - ) - ); -} - -$form->display(); - -echo $OUTPUT->footer(); diff --git a/change_binding_username_claim_tool.php b/change_binding_username_claim_tool.php index 11a7349d5..ced77cb86 100644 --- a/change_binding_username_claim_tool.php +++ b/change_binding_username_claim_tool.php @@ -27,6 +27,8 @@ use auth_oidc\form\change_binding_username_claim_tool_form2; use auth_oidc\preview; use auth_oidc\process; +use core\context\system; +use core\url; require_once(dirname(__FILE__) . '/../../config.php'); require_once($CFG->libdir . '/adminlib.php'); @@ -34,9 +36,9 @@ require_login(); -$url = new moodle_url('/auth/oidc/change_binding_username_claim_tool.php'); +$url = new url('/auth/oidc/change_binding_username_claim_tool.php'); $PAGE->set_url($url); -$PAGE->set_context(context_system::instance()); +$PAGE->set_context(system::instance()); $PAGE->set_pagelayout('admin'); $PAGE->set_heading(get_string('settings_page_change_binding_username_claim_tool', 'auth_oidc')); $PAGE->set_title(get_string('settings_page_change_binding_username_claim_tool', 'auth_oidc')); @@ -70,7 +72,7 @@ echo $OUTPUT->header(); echo $OUTPUT->heading(get_string('change_binding_username_claim_tool', 'auth_oidc')); - $bindingusernameclaimurl = new moodle_url('/auth/oidc/binding_username_claim.php'); + $bindingusernameclaimurl = new url('/admin/settings.php', ['section' => 'auth_oidc_binding_username_claim']); echo html_writer::tag( 'p', get_string( diff --git a/classes/adminsetting/auth_oidc_admin_setting_endpoint.php b/classes/adminsetting/auth_oidc_admin_setting_endpoint.php new file mode 100644 index 000000000..56cda4ec9 --- /dev/null +++ b/classes/adminsetting/auth_oidc_admin_setting_endpoint.php @@ -0,0 +1,129 @@ +. + +/** + * Admin setting class for Microsoft authorization and token endpoint URLs. + * + * @package auth_oidc + * @author Lai Wei + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright (C) 2023 onwards Microsoft, Inc. (http://microsoft.com/) + */ + +namespace auth_oidc\adminsetting; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/auth/oidc/lib.php'); + +/** + * Admin setting for Microsoft authorization or token endpoint URLs. + * + * Extends the standard text setting with validation that ensures the endpoint + * URL version (v1 / v2) matches the selected IdP type, and that tenant-specific + * endpoints are used when certificate authentication is configured on the + * Microsoft Identity Platform (v2) IdP type. + * + * Cross-field validation reads the co-submitted idptype and clientauthmethod + * values from the current request via optional_param() so that the checks + * reflect the values being saved in the same form submission. + */ +class auth_oidc_admin_setting_endpoint extends \admin_setting_configtext { + /** @var string Endpoint type: 'auth' for the authorization endpoint, 'token' for the token endpoint. */ + protected string $endpointtype; + + /** + * Constructor. + * + * @param string $name Setting name in 'plugin/settingname' format. + * @param string $visiblename Visible label shown to the administrator. + * @param string $description Help text shown below the field. + * @param string $defaultsetting Default URL value. + * @param string $endpointtype Either 'auth' or 'token'; governs which version-mismatch + * error string is used. + */ + public function __construct( + string $name, + string $visiblename, + string $description, + string $defaultsetting, + string $endpointtype = 'auth' + ) { + parent::__construct($name, $visiblename, $description, $defaultsetting, PARAM_URL); + $this->endpointtype = $endpointtype; + } + + /** + * Validate the endpoint URL against the IdP type and client authentication method. + * + * For Microsoft IdP types the URL scheme (v1 vs v2) must match the IdP type value. + * When using certificate authentication on the Microsoft Identity Platform (v2), the + * URL must point to a tenant-specific endpoint (not /common/, /organizations/, or + * /consumers/). + * + * @param string $data The submitted URL value. + * @return string|true True when valid; a translatable error string otherwise. + */ + public function validate($data) { + $result = parent::validate($data); + if ($result !== true) { + return $result; + } + + // Read the IdP type and auth method from the current form submission, + // falling back to the saved configuration when the values are absent. + $idptype = (int) optional_param( + 's_auth_oidc_idptype', + get_config('auth_oidc', 'idptype'), + PARAM_INT + ); + $clientauthmethod = (int) optional_param( + 's_auth_oidc_clientauthmethod', + get_config('auth_oidc', 'clientauthmethod'), + PARAM_INT + ); + + if (!in_array($idptype, [AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID, AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM])) { + // Non-Microsoft IdP types have no endpoint version requirements. + return true; + } + + // The endpoint URL version must match the configured IdP type. + $endpointversion = auth_oidc_determine_endpoint_version($data); + if ($endpointversion !== $idptype) { + $mismatchkey = $this->endpointtype === 'token' + ? 'error_endpoint_mismatch_token_endpoint' + : 'error_endpoint_mismatch_auth_endpoint'; + return get_string($mismatchkey, 'auth_oidc'); + } + + // Certificate authentication on Identity Platform requires tenant-specific endpoints. + if ( + $idptype === AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM && + $clientauthmethod === AUTH_OIDC_AUTH_METHOD_CERTIFICATE + ) { + if ( + strpos($data, '/common/') !== false || + strpos($data, '/organizations/') !== false || + strpos($data, '/consumers/') !== false + ) { + return get_string('error_tenant_specific_endpoint_required', 'auth_oidc'); + } + } + + return true; + } +} diff --git a/classes/adminsetting/auth_oidc_admin_setting_iconselect.php b/classes/adminsetting/auth_oidc_admin_setting_iconselect.php index 1fa8c8425..2aeca5491 100644 --- a/classes/adminsetting/auth_oidc_admin_setting_iconselect.php +++ b/classes/adminsetting/auth_oidc_admin_setting_iconselect.php @@ -25,6 +25,8 @@ namespace auth_oidc\adminsetting; +use core\url; + /** * Choose an icon for the identity provider entry on the login page. */ @@ -92,7 +94,7 @@ public function write_setting($data) { public function output_html($data, $query = '') { global $CFG, $OUTPUT; $attrs = ['type' => 'text/css', 'rel' => 'stylesheet', - 'href' => new \moodle_url('/auth/oidc/classes/adminsetting/iconselect.css')]; + 'href' => new url('/auth/oidc/classes/adminsetting/iconselect.css')]; $html = \html_writer::empty_tag('link', $attrs); $html .= \html_writer::start_tag('div', ['style' => 'max-width: 390px']); $selected = (!empty($data)) ? $data : $this->defaultsetting; diff --git a/classes/event/action_failed.php b/classes/event/action_failed.php index ae0c8885a..41ce1f549 100644 --- a/classes/event/action_failed.php +++ b/classes/event/action_failed.php @@ -25,6 +25,8 @@ namespace auth_oidc\event; +use core\context\system; + /** * Event fired whenever we need to record a debug message. */ @@ -53,7 +55,7 @@ public function get_description() { * @return void */ protected function init() { - $this->context = \context_system::instance(); + $this->context = system::instance(); $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_OTHER; } diff --git a/classes/event/user_authed.php b/classes/event/user_authed.php index 97bf2a825..013171a7c 100644 --- a/classes/event/user_authed.php +++ b/classes/event/user_authed.php @@ -25,6 +25,8 @@ namespace auth_oidc\event; +use core\context\system; + /** * Event fired when a user authenticated with OIDC, but does not log in. */ @@ -53,7 +55,7 @@ public function get_description() { * @return void */ protected function init() { - $this->context = \context_system::instance(); + $this->context = system::instance(); $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_OTHER; } diff --git a/classes/event/user_connected.php b/classes/event/user_connected.php index 3f543a996..0818c1faf 100644 --- a/classes/event/user_connected.php +++ b/classes/event/user_connected.php @@ -25,6 +25,8 @@ namespace auth_oidc\event; +use core\context\system; + /** * Fired when a user connects to OpenID Connect. */ @@ -53,7 +55,7 @@ public function get_description() { * @return void */ protected function init() { - $this->context = \context_system::instance(); + $this->context = system::instance(); $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_OTHER; $this->data['objecttable'] = 'user'; diff --git a/classes/event/user_created.php b/classes/event/user_created.php index cfcb24b97..0335097ab 100644 --- a/classes/event/user_created.php +++ b/classes/event/user_created.php @@ -25,6 +25,8 @@ namespace auth_oidc\event; +use core\context\system; + /** * Event fired when OIDC creates a new user. */ @@ -53,7 +55,7 @@ public function get_description() { * @return void */ protected function init() { - $this->context = \context_system::instance(); + $this->context = system::instance(); $this->data['crud'] = 'c'; $this->data['edulevel'] = self::LEVEL_OTHER; $this->data['objecttable'] = 'user'; diff --git a/classes/event/user_disconnected.php b/classes/event/user_disconnected.php index 5fe9ce37d..fadc97cd9 100644 --- a/classes/event/user_disconnected.php +++ b/classes/event/user_disconnected.php @@ -25,6 +25,8 @@ namespace auth_oidc\event; +use core\context\system; + /** * Fired when a user disconnects from OpenID Connect. */ @@ -53,7 +55,7 @@ public function get_description() { * @return void */ protected function init() { - $this->context = \context_system::instance(); + $this->context = system::instance(); $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_OTHER; $this->data['objecttable'] = 'user'; diff --git a/classes/event/user_loggedin.php b/classes/event/user_loggedin.php index 1c5c0342a..a02f85edd 100644 --- a/classes/event/user_loggedin.php +++ b/classes/event/user_loggedin.php @@ -25,6 +25,8 @@ namespace auth_oidc\event; +use core\context\system; + /** * Fired when a user uses OIDC to log in. */ @@ -53,7 +55,7 @@ public function get_description() { * @return void */ protected function init() { - $this->context = \context_system::instance(); + $this->context = system::instance(); $this->data['crud'] = 'r'; $this->data['edulevel'] = self::LEVEL_OTHER; $this->data['objecttable'] = 'user'; diff --git a/classes/event/user_rename_attempt.php b/classes/event/user_rename_attempt.php index 86bb7971f..9fa044267 100644 --- a/classes/event/user_rename_attempt.php +++ b/classes/event/user_rename_attempt.php @@ -25,7 +25,7 @@ namespace auth_oidc\event; -use context_system; +use core\context\system; use core\event\base; /** @@ -56,7 +56,7 @@ public function get_description() { * @return void */ protected function init() { - $this->context = context_system::instance(); + $this->context = system::instance(); $this->data['crud'] = 'u'; $this->data['edulevel'] = self::LEVEL_OTHER; $this->data['objecttable'] = 'user'; diff --git a/classes/form/binding_username_claim.php b/classes/form/binding_username_claim.php deleted file mode 100644 index e3ffaf2cc..000000000 --- a/classes/form/binding_username_claim.php +++ /dev/null @@ -1,138 +0,0 @@ -. - -/** - * Manage binding username claim form. - * - * @package auth_oidc - * @author Lai Wei - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @copyright (C) 2022 onwards Microsoft, Inc. (http://microsoft.com/) - */ - -namespace auth_oidc\form; - -use moodle_exception; -use moodleform; - -defined('MOODLE_INTERNAL') || die(); - -require_once($CFG->dirroot . '/auth/oidc/lib.php'); - -/** - * Class bindingusernameclaim represents the form on the binding username claim configuration page. - */ -class binding_username_claim extends moodleform { - /** - * Option for setting a non-Microsoft IdP. - */ - const OPTION_SET_NON_MS_IDP = 1; - - /** - * Option for setting a Microsoft IdP without user sync. - */ - const OPTION_SET_MS_NO_USER_SYNC = 2; - - /** - * Option for setting a Microsoft IdP with user sync enabled. - */ - const OPTION_SET_MS_WITH_USER_SYNC = 3; - - /** @var int */ - private $optionset = 0; - - /** - * Form definition. - * - * @return void - */ - protected function definition() { - $mform =& $this->_form; - - // Binding username claim. - $idptype = get_config('auth_oidc', 'idptype'); - $bindingusernameoptions = []; - switch ($idptype) { - case AUTH_OIDC_IDP_TYPE_OTHER: - $this->optionset = self::OPTION_SET_NON_MS_IDP; - $descriptionidentifier = 'binding_username_claim_help_non_ms'; - $bindingusernameoptions = [ - 'auto' => get_string('binding_username_auto', 'auth_oidc'), // Use default logic. - 'preferred_username' => 'preferred_username', - 'email' => 'email', - 'unique_name' => 'unique_name', - 'sub' => 'sub', - 'samaccountname' => 'samaccountname', - 'custom' => get_string('binding_username_custom', 'auth_oidc'), // Custom value. - ]; - break; - case AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM: - case AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID: - if (auth_oidc_is_local_365_installed() && auth_oidc_is_user_sync_enabled()) { - $this->optionset = self::OPTION_SET_MS_WITH_USER_SYNC; - $descriptionidentifier = 'binding_username_claim_help_ms_with_user_sync'; - $bindingusernameoptions = [ - 'auto' => get_string('binding_username_auto', 'auth_oidc'), // Use default logic. - 'email' => 'email', - 'upn' => 'upn', - 'oid' => 'oid', - 'samaccountname' => 'samaccountname', - ]; - } else { - $this->optionset = self::OPTION_SET_MS_NO_USER_SYNC; - $descriptionidentifier = 'binding_username_claim_help_ms_no_user_sync'; - $bindingusernameoptions = [ - 'auto' => get_string('binding_username_auto', 'auth_oidc'), // Use default logic. - 'preferred_username' => 'preferred_username', - 'email' => 'email', - 'upn' => 'upn', - 'unique_name' => 'unique_name', - 'oid' => 'oid', - 'sub' => 'sub', - 'samaccountname' => 'samaccountname', - 'custom' => get_string('binding_username_custom', 'auth_oidc'), // Custom value. - ]; - } - break; - } - - if (empty($bindingusernameoptions)) { - throw new moodle_exception('missing_idp_type', 'auth_oidc'); - } - - $mform->addElement( - 'select', - 'bindingusernameclaim', - auth_oidc_config_name_in_form('bindingusernameclaim'), - $bindingusernameoptions - ); - $mform->setDefault('bindingusernameclaim', 'auto'); - $mform->addElement('static', 'bindingusernameclaim_description', '', get_string($descriptionidentifier, 'auth_oidc')); - - // Custom claim name. - if ($this->optionset == self::OPTION_SET_NON_MS_IDP || $this->optionset == self::OPTION_SET_MS_NO_USER_SYNC) { - $mform->addElement('text', 'customclaimname', auth_oidc_config_name_in_form('customclaimname'), ['size' => 40]); - $mform->setType('customclaimname', PARAM_TEXT); - $mform->disabledIf('customclaimname', 'bindingusernameclaim', 'neq', 'custom'); // Enable only if "Custom" is selected. - - // Custom claim name description. - $mform->addElement('static', 'customclaimname_description', '', get_string('customclaimname_description', 'auth_oidc')); - } - - // Save buttons. - $this->add_action_buttons(); - } -} diff --git a/classes/form/change_binding_username_claim_tool_form1.php b/classes/form/change_binding_username_claim_tool_form1.php index 4e9172d80..97f486d52 100644 --- a/classes/form/change_binding_username_claim_tool_form1.php +++ b/classes/form/change_binding_username_claim_tool_form1.php @@ -28,7 +28,7 @@ use core_text; use csv_import_reader; use html_writer; -use moodle_url; +use core\url; use moodleform; /** @@ -43,7 +43,7 @@ class change_binding_username_claim_tool_form1 extends moodleform { protected function definition() { $mform =& $this->_form; - $url = new moodle_url('/auth/oidc/example.csv'); + $url = new url('/auth/oidc/example.csv'); $link = html_writer::link($url, 'example.csv'); $mform->addElement('static', 'example.csv', get_string('examplecsv', 'auth_oidc'), $link); diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index 32cfd8022..b12867dca 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -35,7 +35,7 @@ use core_text; use core_user; use moodle_exception; -use moodle_url; +use core\url; use pix_icon; use stdClass; @@ -58,34 +58,69 @@ public function loginpage_idp_list($wantsurl) { if (!auth_oidc_is_setup_complete()) { return []; } - - if (!empty($this->config->customicon)) { - $icon = new pix_icon('0/customicon', get_string('pluginname', 'auth_oidc'), 'auth_oidc'); - } else { - $icon = (!empty($this->config->icon)) ? $this->config->icon : 'auth_oidc:o365'; - $icon = explode(':', $icon); - if (isset($icon[1])) { - [$iconcomponent, $iconkey] = $icon; + $showicon = isset($this->config->set_pix) ? $this->config->set_pix : true; + $idpentry = [ + 'url' => new url('/auth/oidc/', ['source' => 'loginpage']), + 'name' => strip_tags(format_text($this->config->opname)), + ]; + if ($showicon) { + if (!empty($this->config->customicon)) { + $iconvalue = new pix_icon('0/customicon', get_string('pluginname', 'auth_oidc'), 'auth_oidc'); } else { - $iconcomponent = 'auth_oidc'; - $iconkey = 'o365'; + $icon = (!empty($this->config->icon)) ? $this->config->icon : 'auth_oidc:o365'; + $icon = explode(':', $icon); + if (isset($icon[1])) { + [$iconcomponent, $iconname] = $icon; + } else { + $iconcomponent = 'auth_oidc'; + $iconname = 'o365'; + } + $iconvalue = new pix_icon($iconname, get_string('pluginname', 'auth_oidc'), $iconcomponent); } - $icon = new pix_icon($iconkey, get_string('pluginname', 'auth_oidc'), $iconcomponent); + $idpentry['icon'] = $iconvalue; } + return [$idpentry]; + } - return [ - [ - 'url' => new moodle_url('/auth/oidc/', ['source' => 'loginpage']), - 'icon' => $icon, - 'name' => strip_tags(format_text($this->config->opname)), - ], - ]; + /** + * Validate that a URL is local to this Moodle installation. + * + * @param string $urlstring The URL to validate (as string). + * @return bool True if URL is safe to use as a redirect destination. + */ + protected function is_valid_local_url(string $urlstring): bool { + global $CFG; + + // Parse both URLs to compare components reliably. + $wwwroot = parse_url($CFG->wwwroot); + $checkurl = parse_url($urlstring); + + if (!$wwwroot || !$checkurl) { + return false; + } + + // Scheme and host must match exactly. + if (($wwwroot['scheme'] ?? '') !== ($checkurl['scheme'] ?? '')) { + return false; + } + if (($wwwroot['host'] ?? '') !== ($checkurl['host'] ?? '')) { + return false; + } + + // Port must match if present. + if (($wwwroot['port'] ?? null) !== ($checkurl['port'] ?? null)) { + return false; + } + + return true; } /** * Get an OIDC parameter. * - * This is a modification to PARAM_ALPHANUMEXT to add a few additional characters from Base64-variants. + * Validates the parameter against visible ASCII characters (0x21-0x7E), excluding spaces. + * While RFC 6749 allows VSCHAR (0x20-0x7E), we exclude space for practical URL parsing safety. + * This allows authorization codes from any RFC-compliant OIDC provider that uses visible ASCII. * * @param string $name The name of the parameter. * @param string $fallback The fallback value. @@ -94,7 +129,7 @@ public function loginpage_idp_list($wantsurl) { protected function getoidcparam($name, $fallback = '') { $val = optional_param($name, $fallback, PARAM_RAW); $val = trim($val); - $valclean = preg_replace('/[^A-Za-z0-9\_\-\.\+\/\=]/i', '', $val); + $valclean = preg_replace('/[^\x21-\x7E]/', '', $val); if ($valclean !== $val) { utils::debug('Authorization error.', __METHOD__, $name); throw new moodle_exception('errorauthgeneral', 'auth_oidc'); @@ -117,14 +152,14 @@ public function handleredirect() { if ($silentloginmode) { if ($error == 'login_required') { // If silent login mode is enabled and the error is 'login_required', redirect to the login page. - $loginpageurl = new moodle_url('/login/index.php', ['noredirect' => 1]); + $loginpageurl = new url('/login/index.php', ['noredirect' => 1]); redirect($loginpageurl); die(); } else if ($error == 'interaction_required') { if (strpos($errordescription, 'multiple user identities') !== false) { $selectaccount = true; } else { - $loginpageurl = new moodle_url('/login/index.php', ['noredirect' => 1]); + $loginpageurl = new url('/login/index.php', ['noredirect' => 1]); redirect($loginpageurl); die(); } @@ -164,13 +199,25 @@ public function handleredirect() { $urltogo = $SESSION->wantsurl; unset($SESSION->wantsurl); } else { - $urltogo = new moodle_url('/'); + $urltogo = new url('/'); } redirect($urltogo); die(); } + // Handle guest account session termination. + if (isguestuser()) { + \core\session\manager::terminate_current(); + } // Initial login request. $stateparams = ['forceflow' => 'authcode']; + if (!empty($SESSION->wantsurl)) { + // Normalize to string in case it's a core\url object. + $wantsurl = ($SESSION->wantsurl instanceof url) ? $SESSION->wantsurl->out() : (string)$SESSION->wantsurl; + // Validate URL is local using safe domain comparison. + if ($this->is_valid_local_url($wantsurl)) { + $stateparams['wantsurl'] = $wantsurl; + } + } $extraparams = []; if ($promptaconsent === true) { $extraparams = ['prompt' => 'admin_consent']; @@ -288,7 +335,7 @@ protected function handlecertadminconsentresponse(array $authparams) { $event->trigger(); $redirect = (!empty($additionaldata['redirect'])) ? $additionaldata['redirect'] : '/auth/oidc/ucp.php'; - redirect(new moodle_url($redirect)); + redirect(new url($redirect)); } /** @@ -388,7 +435,7 @@ protected function handleauthresponse(array $authparams) { } else { throw new moodle_exception('errorinvalidredirect_message', 'auth_oidc'); } - redirect(new moodle_url($redirect)); + redirect(new url($redirect)); } // If the user is already logged in we can treat this as a "migration" - a user switching to OIDC. @@ -398,10 +445,22 @@ protected function handleauthresponse(array $authparams) { } $this->handlemigration($oidcuniqid, $authparams, $tokenparams, $idtoken, $connectiononly); $redirect = (!empty($additionaldata['redirect'])) ? $additionaldata['redirect'] : '/auth/oidc/ucp.php'; - redirect(new moodle_url($redirect)); + redirect(new url($redirect)); } else { // Otherwise it's a user logging in normally with OIDC. $this->handlelogin($oidcuniqid, $authparams, $tokenparams, $idtoken); + if (!empty($additionaldata['wantsurl'])) { + // Normalize to string in case it's a core\url object from unserialization. + if ($additionaldata['wantsurl'] instanceof url) { + $wantsurl = $additionaldata['wantsurl']->out(); + } else { + $wantsurl = (string)$additionaldata['wantsurl']; + } + // Validate URL is local using safe domain comparison. + if ($this->is_valid_local_url($wantsurl)) { + $SESSION->wantsurl = $wantsurl; + } + } if ($USER->id && $DB->record_exists('auth_oidc_token', ['userid' => $USER->id])) { $authoidsidrecord = new stdClass(); $authoidsidrecord->userid = $USER->id; @@ -586,7 +645,9 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok } } - $supportuseridentifierchangeconfig = get_config('local_o365', 'support_user_identifier_change'); + $supportuseridentifierchangeconfig = auth_oidc_is_local_365_installed() + ? get_config('local_o365', 'support_user_identifier_change') + : 0; if (!empty($tokenrec)) { // Already connected user. @@ -600,8 +661,8 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok $user = $DB->get_record('user', ['username' => $tokenrec->username]); } - if (empty($user)) { - // Token exists, but it doesn't have a valid username. + if (empty($user) || $user->username != strtolower($tokenrec->username)) { + // Token exists, but it doesn't have a valid username or username doesn't match token. // In this case, delete the token, and try to process login again. $DB->delete_records('auth_oidc_token', ['id' => $tokenrec->id]); return $this->handlelogin($oidcuniqid, $authparams, $tokenparams, $idtoken); @@ -628,14 +689,14 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok if ($usernamechanged) { if ($supportuseridentifierchangeconfig != 1) { // Username change is not supported, throw exception. - throw new moodle_exception('errorupnchangeisnotsupported', 'local_o365', null, null, '2'); + throw new moodle_exception('errorupnchangeisnotsupported', 'auth_oidc', null, null, '2'); } $potentialduplicateuser = core_user::get_user_by_username(strtolower($oidcusername)); - if ($potentialduplicateuser) { - // Username already exists, cannot change Moodle account username, throw exception. + if ($potentialduplicateuser && $potentialduplicateuser->id != $tokenrec->userid) { + // Username already exists in another user, cannot change Moodle account username, throw exception. throw new moodle_exception('erroruserwithusernamealreadyexists', 'auth_oidc', null, null, '2'); } else { - // Username does not exist: + // Username does not exist or belongs to the same user: // 1. can change Moodle account username (if the user uses auth_oidc), // 2. can change token record. if ($user->auth == 'oidc') { @@ -691,7 +752,7 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok // 3. update connection record in local_o365_objects table. if ($supportuseridentifierchangeconfig != 1) { - throw new moodle_exception('errorupnchangeisnotsupported', 'local_o365', null, null, '2'); + throw new moodle_exception('errorupnchangeisnotsupported', 'auth_oidc', null, null, '2'); } $existinguser = core_user::get_user($existingmatching->moodleid); @@ -740,8 +801,10 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok $this->createtoken($oidcuniqid, $username, $authparams, $tokenparams, $idtoken, 0, $originalupn); // Update connection record in local_o365_objects table. - $existingmatching->o365name = $oidcusername; - $DB->update_record('local_o365_objects', $existingmatching); + if (auth_oidc_is_local_365_installed()) { + $existingmatching->o365name = $oidcusername; + $DB->update_record('local_o365_objects', $existingmatching); + } $user = authenticate_user_login($username, '', true); diff --git a/classes/loginflow/base.php b/classes/loginflow/base.php index a73d37b05..7a1b7e501 100644 --- a/classes/loginflow/base.php +++ b/classes/loginflow/base.php @@ -29,6 +29,8 @@ use auth_oidc\jwt; use auth_oidc\oidcclient; use auth_oidc\utils; +use core\context\system; +use core\url; use core_user; use moodle_exception; use stdClass; @@ -141,6 +143,17 @@ public function get_userinfo($username) { $userdata = $apiclient->get_user($tokenrec->oidcuniqid); if ($userdata) { $userdatafetchedfromgraph = true; + // Add custom claims from tokens even when using Graph API. + $tokenames = ['idtoken', 'token']; + foreach ($tokenames as $tokename) { + try { + $token = jwt::instance_from_encoded($tokenrec->$tokename); + $this->add_configured_custom_claims_to_userdata($userdata, $token); + } catch (moodle_exception $e) { + // Error occurred when decoding a token, skip. + continue; + } + } } } } @@ -213,6 +226,8 @@ public function get_userinfo($username) { $userdata['bindingusernameclaim'] = $token->claim($bindingusernameclaim); } } + + $this->add_configured_custom_claims_to_userdata($userdata, $token); } } @@ -300,6 +315,8 @@ public function get_userinfo($username) { $userdata['bindingusernameclaim'] = $token->claim($bindingusernameclaim); } } + + $this->add_configured_custom_claims_to_userdata($userdata, $token); } $updateduser = static::apply_configured_fieldmap_from_token($userdata, $eventtype); @@ -353,23 +370,23 @@ public function set_httpclient(\auth_oidc\httpclientinterface $httpclient) { * @param bool $justremovetokens If true, just remove the stored OIDC tokens for the user; otherwise, revert login methods. * @param bool $donotremovetokens If true, do not remove tokens when disconnecting. This migrates from a login account * to a "linked" account. - * @param \moodle_url|null $redirect URL to redirect to if successful. - * @param \moodle_url|null $selfurl The page this is accessed from, used for some redirects. + * @param url|null $redirect URL to redirect to if successful. + * @param url|null $selfurl The page this is accessed from, used for some redirects. * @param int|null $userid ID of the user to disconnect; uses the current user if not provided. */ public function disconnect( $justremovetokens = false, $donotremovetokens = false, - ?\moodle_url $redirect = null, - ?\moodle_url $selfurl = null, + ?url $redirect = null, + ?url $selfurl = null, $userid = null ) { global $USER, $DB, $CFG; if ($redirect === null) { - $redirect = new \moodle_url('/auth/oidc/ucp.php'); + $redirect = new url('/auth/oidc/ucp.php'); } if ($selfurl === null) { - $selfurl = new \moodle_url('/auth/oidc/ucp.php', ['action' => 'disconnectlogin']); + $selfurl = new url('/auth/oidc/ucp.php', ['action' => 'disconnectlogin']); } // Get the record of the user involved. Current user if no ID received. @@ -393,7 +410,7 @@ public function disconnect( global $OUTPUT, $PAGE; require_once($CFG->dirroot . '/user/lib.php'); $PAGE->set_url($selfurl->out()); - $PAGE->set_context(\context_system::instance()); + $PAGE->set_context(system::instance()); $PAGE->set_pagelayout('standard'); $USER->editing = false; @@ -794,4 +811,28 @@ protected function get_oidc_username_from_token_claim(jwt $idtoken, string $bind return $oidcusername; } + + /** + * Add configured custom claims from a token into the user data array. + * + * @param array $userdata User data array to update. + * @param jwt $token The JWT token to extract claims from. + */ + protected function add_configured_custom_claims_to_userdata(array &$userdata, jwt $token): void { + $customclaims = auth_oidc_get_validated_custom_claim_names(); + if (empty($customclaims)) { + return; + } + + foreach ($customclaims as $claimname) { + if (isset($userdata[$claimname])) { + continue; + } + + $claimvalue = $token->claim($claimname); + if (is_scalar($claimvalue) && $claimvalue !== null && $claimvalue !== '') { + $userdata[$claimname] = $claimvalue; + } + } + } } diff --git a/classes/oidcclient.php b/classes/oidcclient.php index 7961da7d4..54bf6b59b 100644 --- a/classes/oidcclient.php +++ b/classes/oidcclient.php @@ -26,7 +26,7 @@ namespace auth_oidc; use moodle_exception; -use moodle_url; +use core\url; defined('MOODLE_INTERNAL') || die(); @@ -283,7 +283,7 @@ public function authrequest( } $params = $this->getauthrequestparams($promptlogin, $stateparams, $extraparams, $selectaccount); - $redirecturl = new moodle_url($this->endpoints['auth'], $params); + $redirecturl = new url($this->endpoints['auth'], $params); redirect($redirecturl); } @@ -297,7 +297,7 @@ public function authrequest( public function adminconsentrequest(array $stateparams = [], array $extraparams = []) { $adminconsentendpoint = 'https://login.microsoftonline.com/organizations/v2.0/adminconsent'; $params = $this->getadminconsentrequestparams($stateparams, $extraparams); - $redirecturl = new moodle_url($adminconsentendpoint, $params); + $redirecturl = new url($adminconsentendpoint, $params); redirect($redirecturl); } diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 79ac092c4..2dda27996 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -25,6 +25,7 @@ namespace auth_oidc\privacy; +use core\context\user; use core_privacy\local\metadata\collection; use core_privacy\local\metadata\provider as metadata_provider; use core_privacy\local\request\approved_contextlist; @@ -132,7 +133,7 @@ public static function get_contexts_for_userid(int $userid): contextlist { public static function get_users_in_context(\core_privacy\local\request\userlist $userlist) { $context = $userlist->get_context(); - if (!$context instanceof \context_user) { + if (!$context instanceof user) { return; } @@ -174,7 +175,7 @@ public static function get_users_in_context(\core_privacy\local\request\userlist public static function export_user_data(approved_contextlist $contextlist) { global $DB; $user = $contextlist->get_user(); - $context = \context_user::instance($contextlist->get_user()->id); + $context = user::instance($contextlist->get_user()->id); $tables = static::get_table_user_map($user); foreach ($tables as $table => $filterparams) { $records = $DB->get_recordset($table, $filterparams); @@ -250,7 +251,7 @@ private static function delete_user_data(int $userid) { public static function delete_data_for_users(\core_privacy\local\request\approved_userlist $userlist) { $context = $userlist->get_context(); // Because we only use user contexts the instance ID is the user ID. - if ($context instanceof \context_user) { + if ($context instanceof user) { self::delete_user_data($context->instanceid); } } diff --git a/classes/utils.php b/classes/utils.php index 8766c46a0..5dbaf86dd 100644 --- a/classes/utils.php +++ b/classes/utils.php @@ -28,7 +28,7 @@ use Exception; use moodle_exception; use auth_oidc\event\action_failed; -use moodle_url; +use core\url; /** * General purpose utility class. @@ -62,7 +62,7 @@ public static function process_json_response($response, array $expectedstructure isset($result['error_codes']) && count($result['error_codes']) == 1 && $result['error_codes'][0] == 53003 ) { - $localo365configurationpageurl = new moodle_url('/admin/settings.php', ['section' => 'local_o365']); + $localo365configurationpageurl = new url('/admin/settings.php', ['section' => 'local_o365']); throw new moodle_exception( 'settings_adminconsent_error_53003', 'local_o365', @@ -194,7 +194,7 @@ private static function make_json_safe($data) { * @return string The redirect URL. */ public static function get_redirecturl() { - $redirecturl = new moodle_url('/auth/oidc/'); + $redirecturl = new url('/auth/oidc/'); return $redirecturl->out(false); } @@ -204,7 +204,7 @@ public static function get_redirecturl() { * @return string The redirect URL. */ public static function get_frontchannellogouturl() { - $logouturl = new moodle_url('/auth/oidc/logout.php'); + $logouturl = new url('/auth/oidc/logout.php'); return $logouturl->out(false); } diff --git a/cleanupoidctokens.php b/cleanupoidctokens.php index 5e401afe0..604f56f10 100644 --- a/cleanupoidctokens.php +++ b/cleanupoidctokens.php @@ -23,14 +23,17 @@ * @copyright (C) 2014 onwards Microsoft, Inc. (http://microsoft.com/) */ +use core\context\system; +use core\url; + require_once(__DIR__ . '/../../config.php'); require_once($CFG->libdir . '/adminlib.php'); require_once($CFG->dirroot . '/auth/oidc/lib.php'); require_login(); -$context = context_system::instance(); -$pageurl = new moodle_url('/auth/oidc/cleanupoidctokens.php'); +$context = system::instance(); +$pageurl = new url('/auth/oidc/cleanupoidctokens.php'); admin_externalpage_setup('auth_oidc_cleanup_oidc_tokens'); diff --git a/db/install.php b/db/install.php index 768eef90f..b8612d116 100644 --- a/db/install.php +++ b/db/install.php @@ -27,9 +27,18 @@ * Installation script. */ function xmldb_auth_oidc_install() { + global $DB; + // Set the default value for the bindingusernameclaim setting. $bindingusernameclaimconfig = get_config('auth_oidc', 'bindingusernameclaim'); if (empty($bindingusernameclaimconfig)) { set_config('bindingusernameclaim', 'auto', 'auth_oidc'); } + + // Create unique constraint on (oidcuniqid, tokenresource) to prevent duplicate tokens. + // Use CREATE UNIQUE INDEX which works on both MySQL and PostgreSQL. + // Note: PostgreSQL doesn't support column length prefixes, so we use full columns. + // For MySQL, the columns are naturally short enough (GUID + resource URL). + $sql = 'CREATE UNIQUE INDEX idx_oidc_unique ON {auth_oidc_token} (oidcuniqid, tokenresource)'; + $DB->execute($sql); } diff --git a/db/install.xml b/db/install.xml index 023fff036..038f48779 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - diff --git a/db/upgrade.php b/db/upgrade.php index 30566e670..c4d776684 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -581,5 +581,49 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025100600.01, 'auth', 'oidc'); } + if ($oldversion < 2025100601.01) { + upgrade_auth_oidc_add_token_constraint(); + upgrade_plugin_savepoint(true, 2025100601.01, 'auth', 'oidc'); + } + return true; } + +/** + * Helper function to add unique constraint and remove duplicate tokens. + * + * Removes duplicate tokens, keeping the latest one for each (oidcuniqid, tokenresource) pair. + * Uses a temporary table to work around MySQL error 1093 and PostgreSQL parameter limits. + */ +function upgrade_auth_oidc_add_token_constraint(): void { + global $DB; + + try { + $temptable = 'auth_oidc_token_keep_ids'; + + // Step 1: Create a temporary table with the IDs to keep. + $sql = "CREATE TEMPORARY TABLE {" . $temptable . "} (id INT PRIMARY KEY)"; + $DB->execute($sql); + + // Step 2: Insert the IDs to keep (latest token for each oidcuniqid, tokenresource pair). + $sql = "INSERT INTO {" . $temptable . "} (id) + SELECT MAX(id) FROM {auth_oidc_token} + GROUP BY oidcuniqid, tokenresource"; + $DB->execute($sql); + + // Step 3: Delete duplicates not in the temporary table. + $sql = "DELETE FROM {auth_oidc_token} WHERE id NOT IN (SELECT id FROM {" . $temptable . "})"; + $DB->execute($sql); + + // Step 4: Drop the temporary table (automatic on transaction end, but explicit for clarity). + $sql = "DROP TEMPORARY TABLE IF EXISTS {" . $temptable . "}"; + $DB->execute($sql); + + // Step 5: Add unique constraint on (oidcuniqid, tokenresource) to prevent duplicate tokens. + // Use CREATE UNIQUE INDEX which works on both MySQL and PostgreSQL. + $sql = 'CREATE UNIQUE INDEX idx_oidc_unique ON {auth_oidc_token} (oidcuniqid, tokenresource)'; + $DB->execute($sql); + } catch (Exception $e) { + unset($e); + } +} diff --git a/index.php b/index.php index d5f0c7908..17ed6b530 100644 --- a/index.php +++ b/index.php @@ -23,6 +23,9 @@ * @copyright (C) 2014 onwards Microsoft, Inc. (http://microsoft.com/) */ +use core\context\system; +use core\url; + // phpcs:ignore moodle.Files.RequireLogin.Missing require_once(__DIR__ . '/../../config.php'); require_once(__DIR__ . '/auth.php'); @@ -54,13 +57,13 @@ } $PAGE->set_url('/auth/oidc/'); - $PAGE->set_context(context_system::instance()); + $PAGE->set_context(system::instance()); $PAGE->set_pagelayout('login'); $PAGE->set_title(get_string('error')); echo $OUTPUT->header(); echo $OUTPUT->notification($errormessage, 'error'); - echo $OUTPUT->single_button(new moodle_url('/login/index.php'), get_string('login'), 'get'); + echo $OUTPUT->single_button(new url('/login/index.php'), get_string('login'), 'get'); echo $OUTPUT->footer(); exit; } diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index 0fe9cc52c..c6031d7e0 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -35,7 +35,7 @@ // Configuration pages. $string['settings_page_other_settings'] = 'Other options'; $string['settings_page_application'] = 'IdP and authentication'; -$string['settings_page_binding_username_claim'] = 'Binding Username Claim'; +$string['settings_page_binding_username_claim'] = 'Binding username claim'; $string['settings_page_change_binding_username_claim_tool'] = 'Change binding username claim tool'; $string['settings_page_cleanup_oidc_tokens'] = 'Cleanup OpenID Connect tokens'; $string['settings_page_field_mapping'] = 'Field mappings'; @@ -51,6 +51,8 @@ $string['heading_display_desc'] = ''; $string['heading_debugging'] = 'Debugging'; $string['heading_debugging_desc'] = ''; +$string['heading_tools'] = 'Tools'; +$string['heading_tools_desc'] = ''; $string['idptype'] = 'Identity Provider (IdP) Type'; $string['idptype_help'] = 'Three types of IdP are currently supported:
    @@ -65,6 +67,7 @@ $string['idp_type_microsoft_identity_platform'] = 'Microsoft identity platform (v2.0)'; $string['idp_type_other'] = 'Other'; $string['cfg_authenticationlink_desc'] = 'Link to IdP and authentication configuration'; +$string['settings_application_wizard_desc'] = 'To configure these settings using a guided form with step-by-step instructions and input validation, use the Application Configuration Wizard.'; $string['authendpoint'] = 'Authorization Endpoint'; $string['authendpoint_help'] = 'The URI of the Authorization endpoint from your IdP to use.
    Note if the site is to be configured to allow users from other tenants to access, tenant specific authorization endpoint cannot be used.'; @@ -112,6 +115,8 @@ $string['cfg_err_invalidclientsecret'] = 'Invalid client secret'; $string['cfg_forceredirect_key'] = 'Force redirect'; $string['cfg_forceredirect_desc'] = 'If enabled, will skip the login index page and redirect to the OpenID Connect page. Can be bypassed with ?noredirect=1 URL param'; +$string['cfg_set_pix_key'] = 'Show icon on login page'; +$string['cfg_set_pix_desc'] = 'If enabled, displays an icon next to the provider name on the login page.'; $string['cfg_icon_key'] = 'Icon'; $string['cfg_icon_desc'] = 'An icon to display next to the provider name on the login page.'; $string['cfg_iconalt_o365'] = 'Microsoft 365 icon'; @@ -179,7 +184,7 @@ $string['tokenendpoint_help'] = 'The URI of the token endpoint from your IdP to use.
    Note if the site is to be configured to allow users from other tenants to access, tenant specific token endpoint cannot be used.'; $string['cfg_userrestrictions_key'] = 'User Restrictions'; -$string['cfg_userrestrictions_desc'] = 'Only allow users to log in that meet certain restrictions.
    How to use user restrictions:
    • Enter a regular expression pattern that matches the usernames of users you want to allow.
    • Enter one pattern per line
    • If you enter multiple patterns a user will be allowed if they match ANY of the patterns.
    • The character "/" should be escaped with "\".
    • If you don\'t enter any restrictions above, all users that can log in to the OpenID Connect IdP will be accepted by Moodle.
    • Any user that does not match any entered pattern(s) will be prevented from logging in using OpenID Connect.
    '; +$string['cfg_userrestrictions_desc'] = 'Only allow users that meet certain restrictions to log in.
    How to use user restrictions:
    • Enter a regular expression pattern that matches the usernames of users you want to allow.
    • Enter one pattern per line
    • If you enter multiple patterns a user will be allowed if they match ANY of the patterns.
    • The character "/" should be escaped with "\".
    • If you don\'t enter any restrictions above, all users that can log in to the OpenID Connect IdP will be accepted by Moodle.
    • Any user that does not match any entered pattern(s) will be prevented from logging in using OpenID Connect.
    '; $string['cfg_userrestrictionscasesensitive_key'] = 'User Restrictions Case Sensitive'; $string['cfg_userrestrictionscasesensitive_desc'] = 'This controls if the "/i" option in regular expression is used in the user restriction match.
    If enabled, all user restriction checks will be performed as with case sensitive. Note if this is disabled, any patterns on letter cases will be ignored.'; $string['cfg_signoffintegration_key'] = 'Single Sign Out (from Moodle to IdP)'; @@ -209,13 +214,15 @@ $string['settings_section_basic'] = 'Basic settings'; $string['settings_section_authentication'] = 'Authentication'; $string['settings_section_endpoints'] = 'Endpoints'; -$string['settings_section_binding_username_claim'] = 'Binding Username Claim'; +$string['settings_section_binding_username_claim'] = 'Binding username claim'; $string['settings_section_other_params'] = 'Other parameters'; $string['settings_section_secret_expiry_notification'] = 'Secret expiry notification'; $string['authentication_and_endpoints_saved'] = 'Authentication and endpoint settings updated.'; $string['application_updated'] = 'OpenID Connect application setting have been updated.'; $string['application_updated_microsoft'] = 'OpenID Connect application setting was updated.
    Azure administrator will need to Provide admin consent and Verify setup again on the Microsoft 365 integration configuration page if "Identity Provider (IdP) Type" or "Client authentication method" settings are updated.'; +$string['application_updated_microsoft_notify'] = 'OpenID Connect application setting was updated.
    +Azure administrator will need to Provide admin consent and Verify setup again on the Microsoft 365 integration configuration page if "Identity Provider (IdP) Type" or "Client authentication method" settings are updated.'; $string['application_not_changed'] = 'OpenID Connect application setting was not changed.'; $string['event_debug'] = 'Debug message'; @@ -271,19 +278,12 @@ $string['error_empty_client_private_key_file'] = 'Client certificate private key file cannot be empty when using "certificate" authentication method'; $string['error_empty_client_cert_file'] = 'Client certificate public key file cannot be empty when using "certificate" authentication method'; $string['error_empty_tenantname_or_guid'] = 'Tenant name or GUID cannot be empty when using "certificate" authentication method'; -$string['error_endpoint_mismatch_auth_endpoint'] = 'The configured authorization endpoint does not match configured IdP type.
    -
      -
    • When using "Microsoft Entra ID (v1.0)" IdP type, use v1.0 endpoint, e.g. https://login.microsoftonline.com/organizations/oauth2/authorize
    • -
    • When using "Microsoft identity platform (v2.0)" IdP type, use v2.0 endpoint, e.g. https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize
    • -
    '; -$string['error_endpoint_mismatch_token_endpoint'] = 'The configured token endpoint does not match configured IdP type.
    -
      -
    • When using "Microsoft Entra ID (v1.0)" IdP type, use v1.0 endpoint, e.g. https://login.microsoftonline.com/organizations/oauth2/token
    • -
    • When using "Microsoft identity platform (v2.0)" IdP type, use v2.0 endpoint, e.g. https://login.microsoftonline.com/organizations/oauth2/v2.0/token
    • -
    '; +$string['error_endpoint_mismatch_auth_endpoint'] = 'The configured authorization endpoint does not match the configured IdP type. For "Microsoft Entra ID (v1.0)" use a v1.0 endpoint (e.g. https://login.microsoftonline.com/organizations/oauth2/authorize). For "Microsoft identity platform (v2.0)" use a v2.0 endpoint (e.g. https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize).'; +$string['error_endpoint_mismatch_token_endpoint'] = 'The configured token endpoint does not match the configured IdP type. For "Microsoft Entra ID (v1.0)" use a v1.0 endpoint (e.g. https://login.microsoftonline.com/organizations/oauth2/token). For "Microsoft identity platform (v2.0)" use a v2.0 endpoint (e.g. https://login.microsoftonline.com/organizations/oauth2/v2.0/token).'; $string['error_tenant_specific_endpoint_required'] = 'When using "Microsoft identity platform (v2.0)" IdP type and "Certificate" authentication method, tenant specific endpoint (i.e. not common/organizations/consumers) is required.'; $string['error_empty_oidcresource'] = 'Resource cannot be empty when using Microsoft Entra ID (v1.0) or other types of IdP.'; $string['error_invalid_custom_claim'] = 'Invalid custom claim name. Custom claims can only contain alphanumeric characters, hyphens, and underscores.'; +$string['errorupnchangeisnotsupported'] = 'Your Microsoft account UPN has changed. Please contact your administrator to update your Moodle account.'; $string['erroruserwithusernamealreadyexists'] = 'Error occurred when trying to rename your Moodle account. A Moodle user with the new username already exists. Ask your site administrator to resolve this first.'; $string['error_no_response_available'] = 'No responses available.'; @@ -406,7 +406,7 @@ $string['settings_fieldmap_field_sds_teacher_teacherNumber'] = 'SDS teacher number'; // Binding username claim options. -$string['binding_username_claim_heading'] = 'Binding Username Claim'; +$string['binding_username_claim_heading'] = 'Binding username claim'; $string['binding_username_claim_description'] = '

    This is an advanced feature!

    This page allows site administrators to select the token claim to use for binding with Moodle username.

    Be very cautious when changing this setting. Follow the steps below to change this setting on Moodle sites with existing users using OpenID Connect authentication method. Failure to do so may result in users being logged out and/or duplicate accounts being created.

    @@ -426,7 +426,9 @@ $string['binding_username_custom'] = 'Custom'; $string['bindingusernameclaim'] = 'Binding username claim'; $string['customclaimname'] = 'Custom claim name'; -$string['customclaimname_description'] = 'This field is used only when the Binding Username Claim setting is set to Custom.'; +$string['customclaimname_description'] = 'This field is used only when the binding username claim setting is set to Custom.'; +$string['binding_username_claim_tool_link_desc'] = 'To update the binding username claim for existing users, use the Change binding username claim tool.'; +$string['cleanup_oidc_tokens_link_desc'] = 'To clean up obsolete OpenID Connect tokens, use the Cleanup OpenID Connect tokens tool.'; $string['binding_username_claim_help_ms_no_user_sync'] = 'The options for non Microsoft IdPs include:
    • Choose automatically: Uses current logic, determining the token by IdP type and falling back to sub if no claim is found.
    • @@ -458,7 +460,7 @@
    • samaccountname
    • custom: Custom claim.
    '; -$string['binding_username_claim_updated'] = 'Binding Username Claim was updated successfully.'; +$string['binding_username_claim_updated'] = 'Binding username claim was updated successfully.'; $string['examplecsv'] = 'Example upload file'; $string['usernamefile'] = 'File'; $string['csvdelimiter'] = 'CSV separator'; diff --git a/lib.php b/lib.php index f7bd481f0..5d13a5ae0 100644 --- a/lib.php +++ b/lib.php @@ -26,6 +26,9 @@ use auth_oidc\jwt; use auth_oidc\utils; +use core\context\system; +use core\context\user; +use core\url; // IdP types. /** @@ -81,6 +84,118 @@ */ const AUTH_OIDC_AUTH_CERT_SOURCE_FILE = 2; +/** + * Callback invoked when application credentials or endpoint settings are updated. + * + * Clears cached application tokens and the setup verification result so that + * the connection is re-validated with the new values. + * + * @param string $settingname The full name of the setting that was updated. + * @return void + */ +function auth_oidc_reset_app_tokens($settingname) { + // Use a static flag so cache purging and token clearing only happen once per request, + // even when multiple settings with this callback change in the same save. + static $cachespurged = false; + if (!$cachespurged) { + unset_config('apptokens', 'local_o365'); + unset_config('azuresetupresult', 'local_o365'); + purge_all_caches(); + $cachespurged = true; + } + + if (auth_oidc_is_local_365_installed()) { + $idptype = get_config('auth_oidc', 'idptype'); + if ($idptype && $idptype != AUTH_OIDC_IDP_TYPE_OTHER) { + // Use a static flag so only one notification is queued per request, + // even when multiple settings with this callback change in the same save. + static $notificationqueued = false; + if (!$notificationqueued) { + $localo365configurl = new \core\url('/admin/settings.php', ['section' => 'local_o365']); + \core\notification::warning( + get_string('application_updated_microsoft_notify', 'auth_oidc', $localo365configurl->out()) + ); + $notificationqueued = true; + } + } + } +} + +/** + * Validate authentication settings for invalid combinations. + * + * Checks for invalid combinations that could break authentication: + * - Certificate auth with Entra v1/Other IdP types (not supported) + * - Secret auth without a configured client secret + * - Certificate auth without required cert/key fields + * + * @param string $settingname The full name of the setting that was updated. + * @return void + */ +function auth_oidc_validate_auth_settings(string $settingname) { + $idptype = get_config('auth_oidc', 'idptype'); + $clientauthmethod = get_config('auth_oidc', 'clientauthmethod'); + + if (empty($idptype) || empty($clientauthmethod)) { + return; + } + + $errors = []; + + // Validate clientauthmethod according to idptype. + if (in_array($idptype, [AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID, AUTH_OIDC_IDP_TYPE_OTHER])) { + if ($clientauthmethod != AUTH_OIDC_AUTH_METHOD_SECRET) { + $errors[] = get_string('error_invalid_client_authentication_method', 'auth_oidc'); + } + } else if ($idptype == AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM) { + if (!in_array($clientauthmethod, [AUTH_OIDC_AUTH_METHOD_SECRET, AUTH_OIDC_AUTH_METHOD_CERTIFICATE])) { + $errors[] = get_string('error_invalid_client_authentication_method', 'auth_oidc'); + } + } + + // Validate authentication-method-specific requirements. + if ($clientauthmethod == AUTH_OIDC_AUTH_METHOD_SECRET) { + $clientsecret = get_config('auth_oidc', 'clientsecret'); + if (empty($clientsecret)) { + $errors[] = get_string('error_empty_client_secret', 'auth_oidc'); + } + } else if ($clientauthmethod == AUTH_OIDC_AUTH_METHOD_CERTIFICATE) { + $clientcertsource = get_config('auth_oidc', 'clientcertsource'); + + if ($clientcertsource == AUTH_OIDC_AUTH_CERT_SOURCE_TEXT) { + $clientprivatekey = get_config('auth_oidc', 'clientprivatekey'); + $clientcert = get_config('auth_oidc', 'clientcert'); + + if (empty($clientprivatekey)) { + $errors[] = get_string('error_empty_client_private_key', 'auth_oidc'); + } + if (empty($clientcert)) { + $errors[] = get_string('error_empty_client_cert', 'auth_oidc'); + } + } else if ($clientcertsource == AUTH_OIDC_AUTH_CERT_SOURCE_FILE) { + $clientprivatekeyfile = get_config('auth_oidc', 'clientprivatekeyfile'); + $clientcertfile = get_config('auth_oidc', 'clientcertfile'); + + if (empty($clientprivatekeyfile)) { + $errors[] = get_string('error_empty_client_private_key_file', 'auth_oidc'); + } + if (empty($clientcertfile)) { + $errors[] = get_string('error_empty_client_cert_file', 'auth_oidc'); + } + } + } + + // Notify admin if validation errors are found. + if (!empty($errors)) { + $message = get_string('auth_settings_validation_error', 'auth_oidc') . '
      '; + foreach ($errors as $error) { + $message .= '
    • ' . $error . '
    • '; + } + $message .= '
    '; + \core\notification::error($message); + } +} + /** * Initialize custom icon for OIDC authentication. * @@ -94,7 +209,7 @@ function auth_oidc_initialize_customicon($filefullname) { global $CFG; $file = get_config('auth_oidc', 'customicon'); - $systemcontext = \context_system::instance(); + $systemcontext = system::instance(); $fullpath = "/{$systemcontext->id}/auth_oidc/customicon/0{$file}"; $fs = get_file_storage(); @@ -134,24 +249,24 @@ function auth_oidc_connectioncapability($userid, $mode = 'connect', $require = f if ($require) { // If requiring the capability and user has manageconnection than checking connect and disconnect is not needed. $check = 'require_capability'; - if (has_capability('auth/oidc:manageconnection', \context_user::instance($userid), $userid)) { + if (has_capability('auth/oidc:manageconnection', user::instance($userid), $userid)) { return true; } - } else if ($check('auth/oidc:manageconnection', \context_user::instance($userid), $userid)) { + } else if ($check('auth/oidc:manageconnection', user::instance($userid), $userid)) { return true; } $result = false; switch ($mode) { case "connect": - $result = $check('auth/oidc:manageconnectionconnect', \context_user::instance($userid), $userid); + $result = $check('auth/oidc:manageconnectionconnect', user::instance($userid), $userid); break; case "disconnect": - $result = $check('auth/oidc:manageconnectiondisconnect', \context_user::instance($userid), $userid); + $result = $check('auth/oidc:manageconnectiondisconnect', user::instance($userid), $userid); break; case "both": - $result = $check('auth/oidc:manageconnectionconnect', \context_user::instance($userid), $userid); - $result = $result && $check('auth/oidc:manageconnectiondisconnect', \context_user::instance($userid), $userid); + $result = $check('auth/oidc:manageconnectionconnect', user::instance($userid), $userid); + $result = $result && $check('auth/oidc:manageconnectiondisconnect', user::instance($userid), $userid); } if ($require) { return true; @@ -198,7 +313,7 @@ function auth_oidc_get_tokens_with_empty_ids() { $item->oidcuniqueid = $record->oidcuniqid; $item->matchingstatus = get_string('unmatched', 'auth_oidc'); $item->details = get_string('na', 'auth_oidc'); - $deletetokenurl = new moodle_url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id]); + $deletetokenurl = new url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id]); $item->action = html_writer::link($deletetokenurl, get_string('delete_token', 'auth_oidc')); $emptyuseridtokens[$record->id] = $item; @@ -222,7 +337,7 @@ function auth_oidc_get_tokens_with_mismatched_usernames() { FROM {auth_oidc_token} tok JOIN {user} u ON u.id = tok.userid WHERE tok.userid != 0 - AND u.username != tok.username'; + AND LOWER(u.username) != LOWER(tok.username)'; $records = $DB->get_recordset_sql($sql); foreach ($records as $record) { $item = new stdClass(); @@ -237,7 +352,7 @@ function auth_oidc_get_tokens_with_mismatched_usernames() { 'auth_oidc', ['tokenusername' => $record->tokenusername, 'moodleusername' => $record->musername] ); - $deletetokenurl = new moodle_url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id]); + $deletetokenurl = new url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id]); $item->action = html_writer::link($deletetokenurl, get_string('delete_token_and_reference', 'auth_oidc')); $mismatchedtokens[$record->id] = $item; @@ -286,38 +401,56 @@ function auth_oidc_delete_token(int $tokenid): void { } /** - * Process and add custom claims to remote fields array with validation. + * Get validated custom claim names from configuration. * - * @param array $remotefields Existing remote fields array - * @return array Updated remote fields array with validated custom claims + * Parses the customclaims configuration, validates claim name format, and returns + * the list of valid claim names to be used for token claim extraction and field mapping. + * + * @return array Array of validated custom claim names. */ -function auth_oidc_process_custom_claims($remotefields) { +function auth_oidc_get_validated_custom_claim_names() { $customclaimsconfig = get_config('auth_oidc', 'customclaims'); if (empty($customclaimsconfig)) { - return $remotefields; + return []; } // Split by space, trim, remove empty values, and remove duplicates. - $customclaimsarray = array_filter(array_map('trim', explode(' ', $customclaimsconfig))); - $customclaimsarray = array_unique($customclaimsarray); + $customclaims = array_filter(array_map('trim', explode(' ', $customclaimsconfig))); + $customclaims = array_unique($customclaims); - // Get all existing field names as reserved to prevent overriding. - $reserved = array_keys($remotefields); - - foreach ($customclaimsarray as $value) { + $validated = []; + foreach ($customclaims as $claimname) { // Validate claim name format (alphanumeric, underscore, hyphen only). - if (!preg_match('/^[a-zA-Z0-9_-]+$/', $value)) { - debugging("Invalid custom claim name skipped: $value", DEBUG_DEVELOPER); + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $claimname)) { + debugging("Invalid custom claim name skipped: $claimname", DEBUG_DEVELOPER); continue; } + $validated[] = $claimname; + } + return $validated; +} + +/** + * Process and add custom claims to remote fields array with validation. + * + * @param array $remotefields Existing remote fields array + * @return array Updated remote fields array with validated custom claims + */ +function auth_oidc_process_custom_claims($remotefields) { + $customclaims = auth_oidc_get_validated_custom_claim_names(); + + // Get all existing field names as reserved to prevent overriding. + $reserved = array_keys($remotefields); + + foreach ($customclaims as $claimname) { // Prevent overriding existing fields. - if (in_array($value, $reserved, true)) { - debugging("Reserved custom claim name skipped: $value", DEBUG_DEVELOPER); + if (in_array($claimname, $reserved, true)) { + debugging("Reserved custom claim name skipped: $claimname", DEBUG_DEVELOPER); continue; } - $remotefields[$value] = $value; + $remotefields[$claimname] = $claimname; } return $remotefields; @@ -607,7 +740,7 @@ function auth_oidc_display_auth_lock_options( // Generate the list of fields / mappings. if ($fieldnametoolong) { // Display a message that the field can not be mapped because it's too long. - $url = new moodle_url('/user/profile/index.php'); + $url = new url('/user/profile/index.php'); $a = (object)['fieldname' => s($fieldname), 'shortname' => s($field), 'charlimit' => 67, 'link' => $url->out()]; $settings->add(new admin_setting_heading( $auth . '/field_not_mapped_' . sha1($field), @@ -920,3 +1053,40 @@ function auth_oidc_is_masked_secret($value) { // and secrets 2+ chars (masked as XX**********). return preg_match('/^(.{2})?\*{10}$/', $value) === 1; } + +/** + * Build Bootstrap nav-tabs HTML for navigating between auth_oidc settings pages. + * + * Renders a row of tab links to each settings sub-page, with the current page + * marked as active. The "Binding username claim" tab is only included if IdP type + * is configured, since the corresponding settings page is only registered in that case. + * + * @param string $currentpage Section ID of the currently active page. + * @return string HTML for the navigation bar. + */ +function auth_oidc_get_settings_nav_html(string $currentpage): string { + $pages = [ + 'auth_oidc_application' => get_string('settings_page_application', 'auth_oidc'), + ]; + + // Only include the binding username claim tab if IdP type is configured. + $idptype = get_config('auth_oidc', 'idptype'); + if ($idptype) { + $pages['auth_oidc_binding_username_claim'] = get_string('settings_page_binding_username_claim', 'auth_oidc'); + } + + $pages += [ + 'auth_oidc_other_settings' => get_string('settings_page_other_settings', 'auth_oidc'), + 'auth_oidc_field_mapping' => get_string('settings_page_field_mapping', 'auth_oidc'), + ]; + + $html = html_writer::start_tag('ul', ['class' => 'nav nav-tabs mb-3']); + foreach ($pages as $section => $label) { + $url = new \core\url('/admin/settings.php', ['section' => $section]); + $linkattrs = ['class' => 'nav-link' . ($section === $currentpage ? ' active' : '')]; + $html .= html_writer::tag('li', html_writer::link($url, $label, $linkattrs), ['class' => 'nav-item']); + } + $html .= html_writer::end_tag('ul'); + + return $html; +} diff --git a/logout.php b/logout.php index a652f788f..bdbd30a94 100644 --- a/logout.php +++ b/logout.php @@ -23,11 +23,13 @@ * @copyright (C) 2014 onwards Microsoft, Inc. (http://microsoft.com/) */ +use core\context\system; + // phpcs:ignore moodle.Files.RequireLogin.Missing require_once(__DIR__ . '/../../config.php'); $PAGE->set_url('/auth/oidc/logout.php'); -$PAGE->set_context(context_system::instance()); +$PAGE->set_context(system::instance()); $sid = optional_param('sid', '', PARAM_TEXT); diff --git a/manageapplication.php b/manageapplication.php index 57dc77aa2..aac8d2673 100644 --- a/manageapplication.php +++ b/manageapplication.php @@ -24,16 +24,19 @@ */ use auth_oidc\form\application; +use core\context\system; +use core\url; require_once(dirname(__FILE__) . '/../../config.php'); require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->libdir . '/formslib.php'); require_once($CFG->dirroot . '/auth/oidc/lib.php'); require_login(); -$url = new moodle_url('/auth/oidc/manageapplication.php'); +$url = new url('/auth/oidc/manageapplication.php'); $PAGE->set_url($url); -$PAGE->set_context(context_system::instance()); +$PAGE->set_context(system::instance()); $PAGE->set_pagelayout('admin'); $PAGE->set_heading(get_string('settings_page_application', 'auth_oidc')); $PAGE->set_title(get_string('settings_page_application', 'auth_oidc')); @@ -46,7 +49,7 @@ ]; $PAGE->requires->js_init_call('M.auth_oidc.init', $jsparams, true, $jsmodule); -admin_externalpage_setup('auth_oidc_application'); +navigation_node::require_admin_tree(); require_admin(); @@ -186,7 +189,7 @@ purge_all_caches(); // Then show the message to the user with instructions to update the application token. - $localo365configurl = new moodle_url('/admin/settings.php', ['section' => 'local_o365']); + $localo365configurl = new url('/admin/settings.php', ['section' => 'local_o365']); redirect($localo365configurl, get_string('application_updated_microsoft', 'auth_oidc')); } else { redirect($url, get_string('application_updated', 'auth_oidc')); diff --git a/settings.php b/settings.php index dd4255ada..2c1d9774a 100644 --- a/settings.php +++ b/settings.php @@ -26,188 +26,654 @@ defined('MOODLE_INTERNAL') || die(); +use auth_oidc\adminsetting\auth_oidc_admin_setting_endpoint; use auth_oidc\adminsetting\auth_oidc_admin_setting_iconselect; use auth_oidc\adminsetting\auth_oidc_admin_setting_loginflow; use auth_oidc\adminsetting\auth_oidc_admin_setting_redirecturi; use auth_oidc\utils; +use core\url; require_once($CFG->dirroot . '/auth/oidc/lib.php'); if ($hassiteconfig) { + // Redirect the category overview page to the first settings tab, so that the Bootstrap + // nav-tabs behave correctly instead of showing all sub-pages' content at once. + if ($PAGE->has_set_url() && $PAGE->url->get_param('category') === 'oidcfolder') { + redirect(new \core\url('/admin/settings.php', ['section' => 'auth_oidc_application'])); + } + // Add folder for OIDC settings. $oidcfolder = new admin_category('oidcfolder', get_string('pluginname', 'auth_oidc')); $ADMIN->add('authsettings', $oidcfolder); - // Application configuration page. - $ADMIN->add('oidcfolder', new admin_externalpage( + // Application configuration settings page. + $applicationsettings = new admin_settingpage( 'auth_oidc_application', - get_string('settings_page_application', 'auth_oidc'), - new moodle_url('/auth/oidc/manageapplication.php') + get_string('settings_page_application', 'auth_oidc') + ); + + // Add navigation tabs. + $applicationsettings->add(new admin_setting_heading( + 'auth_oidc_application_nav', + '', + auth_oidc_get_settings_nav_html('auth_oidc_application') + )); + + // Link to the guided Application Configuration Wizard. + $wizardurl = new url('/auth/oidc/manageapplication.php'); + $applicationsettings->add(new admin_setting_description( + 'auth_oidc/application_wizard_link', + '', + get_string('settings_application_wizard_desc', 'auth_oidc', $wizardurl->out()) + )); + + // Basic settings heading. + $applicationsettings->add(new admin_setting_heading( + 'auth_oidc/application_basic_heading', + get_string('settings_section_basic', 'auth_oidc'), + '' )); + // Redirect URI. + $applicationsettings->add( + new auth_oidc_admin_setting_redirecturi( + 'auth_oidc/redirecturi', + get_string('cfg_redirecturi_key', 'auth_oidc'), + get_string('cfg_redirecturi_desc', 'auth_oidc'), + utils::get_redirecturl() + ) + ); + + // IdP type. + $idptypeoptions = [ + AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID => get_string('idp_type_microsoft_entra_id', 'auth_oidc'), + AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM => get_string('idp_type_microsoft_identity_platform', 'auth_oidc'), + AUTH_OIDC_IDP_TYPE_OTHER => get_string('idp_type_other', 'auth_oidc'), + ]; + $idptypesetting = new admin_setting_configselect( + 'auth_oidc/idptype', + get_string('idptype', 'auth_oidc'), + get_string('idptype_help', 'auth_oidc'), + AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID, + $idptypeoptions + ); + $idptypesetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $idptypesetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($idptypesetting); + + // Client ID. + $clientidsetting = new admin_setting_configtext( + 'auth_oidc/clientid', + get_string('clientid', 'auth_oidc'), + get_string('clientid_help', 'auth_oidc'), + '', + PARAM_TEXT + ); + $clientidsetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $applicationsettings->add($clientidsetting); + + // Authentication heading. + $applicationsettings->add(new admin_setting_heading( + 'auth_oidc/application_auth_heading', + get_string('settings_section_authentication', 'auth_oidc'), + '' + )); + + // Client authentication method. + $clientauthmethoptions = [ + AUTH_OIDC_AUTH_METHOD_SECRET => get_string('auth_method_secret', 'auth_oidc'), + AUTH_OIDC_AUTH_METHOD_CERTIFICATE => get_string('auth_method_certificate', 'auth_oidc'), + ]; + $clientauthmethodsetting = new admin_setting_configselect( + 'auth_oidc/clientauthmethod', + get_string('clientauthmethod', 'auth_oidc'), + get_string('clientauthmethod_help', 'auth_oidc'), + AUTH_OIDC_AUTH_METHOD_SECRET, + $clientauthmethoptions + ); + $clientauthmethodsetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientauthmethodsetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientauthmethodsetting); + + // Client secret. + $clientsecretsetting = new admin_setting_configpasswordunmask( + 'auth_oidc/clientsecret', + get_string('clientsecret', 'auth_oidc'), + get_string('clientsecret_help', 'auth_oidc'), + '' + ); + $clientsecretsetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientsecretsetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientsecretsetting); + + // Certificate source. + $certsourceoptions = [ + AUTH_OIDC_AUTH_CERT_SOURCE_TEXT => get_string('cert_source_text', 'auth_oidc'), + AUTH_OIDC_AUTH_CERT_SOURCE_FILE => get_string('cert_source_path', 'auth_oidc'), + ]; + $clientcertsourcesetting = new admin_setting_configselect( + 'auth_oidc/clientcertsource', + get_string('clientcertsource', 'auth_oidc'), + get_string('clientcertsource_help', 'auth_oidc'), + AUTH_OIDC_AUTH_CERT_SOURCE_TEXT, + $certsourceoptions + ); + $clientcertsourcesetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientcertsourcesetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientcertsourcesetting); + + // Client certificate private key (plain text). + $clientprivatekeysetting = new admin_setting_configtextarea( + 'auth_oidc/clientprivatekey', + get_string('clientprivatekey', 'auth_oidc'), + get_string('clientprivatekey_help', 'auth_oidc'), + '', + PARAM_TEXT + ); + $clientprivatekeysetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientprivatekeysetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientprivatekeysetting); + + // Client certificate public key (plain text). + $clientcertsetting = new admin_setting_configtextarea( + 'auth_oidc/clientcert', + get_string('clientcert', 'auth_oidc'), + get_string('clientcert_help', 'auth_oidc'), + '', + PARAM_TEXT + ); + $clientcertsetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientcertsetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientcertsetting); + + // Client certificate private key file name. + $clientprivatekeyfilesetting = new admin_setting_configtext( + 'auth_oidc/clientprivatekeyfile', + get_string('clientprivatekeyfile', 'auth_oidc'), + get_string('clientprivatekeyfile_help', 'auth_oidc'), + '', + PARAM_FILE + ); + $clientprivatekeyfilesetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientprivatekeyfilesetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientprivatekeyfilesetting); + + // Client certificate public key file name. + $clientcertfilesetting = new admin_setting_configtext( + 'auth_oidc/clientcertfile', + get_string('clientcertfile', 'auth_oidc'), + get_string('clientcertfile_help', 'auth_oidc'), + '', + PARAM_FILE + ); + $clientcertfilesetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientcertfilesetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientcertfilesetting); + + // Client certificate passphrase. + $clientcertpassphrasesetting = new admin_setting_configpasswordunmask( + 'auth_oidc/clientcertpassphrase', + get_string('clientcertpassphrase', 'auth_oidc'), + get_string('clientcertpassphrase_help', 'auth_oidc'), + '' + ); + $clientcertpassphrasesetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $clientcertpassphrasesetting->set_updatedcallback('auth_oidc_validate_auth_settings'); + $applicationsettings->add($clientcertpassphrasesetting); + + // Endpoints heading. + $applicationsettings->add(new admin_setting_heading( + 'auth_oidc/application_endpoints_heading', + get_string('settings_section_endpoints', 'auth_oidc'), + '' + )); + + // Authorization endpoint. + $authendpointsetting = new auth_oidc_admin_setting_endpoint( + 'auth_oidc/authendpoint', + get_string('authendpoint', 'auth_oidc'), + get_string('authendpoint_help', 'auth_oidc'), + 'https://login.microsoftonline.com/organizations/oauth2/authorize', + 'auth' + ); + $authendpointsetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $applicationsettings->add($authendpointsetting); + + // Token endpoint. + $tokenendpointsetting = new auth_oidc_admin_setting_endpoint( + 'auth_oidc/tokenendpoint', + get_string('tokenendpoint', 'auth_oidc'), + get_string('tokenendpoint_help', 'auth_oidc'), + 'https://login.microsoftonline.com/organizations/oauth2/token', + 'token' + ); + $tokenendpointsetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $applicationsettings->add($tokenendpointsetting); + + // Other parameters heading. + $applicationsettings->add(new admin_setting_heading( + 'auth_oidc/application_otherparams_heading', + get_string('settings_section_other_params', 'auth_oidc'), + '' + )); + + // OIDC resource. + $oidcresourcesetting = new admin_setting_configtext( + 'auth_oidc/oidcresource', + get_string('oidcresource', 'auth_oidc'), + get_string('oidcresource_help', 'auth_oidc'), + 'https://graph.microsoft.com', + PARAM_TEXT + ); + $oidcresourcesetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $applicationsettings->add($oidcresourcesetting); + + // OIDC scope. + $oidcscopesetting = new admin_setting_configtext( + 'auth_oidc/oidcscope', + get_string('oidcscope', 'auth_oidc'), + get_string('oidcscope_help', 'auth_oidc'), + 'openid profile email', + PARAM_TEXT + ); + $oidcscopesetting->set_updatedcallback('auth_oidc_reset_app_tokens'); + $applicationsettings->add($oidcscopesetting); + + // Secret expiry notification (only when local_o365 is installed). + if (auth_oidc_is_local_365_installed()) { + $applicationsettings->add(new admin_setting_heading( + 'auth_oidc/application_secretexpiry_heading', + get_string('settings_section_secret_expiry_notification', 'auth_oidc'), + '' + )); + + $applicationsettings->add(new admin_setting_configtext( + 'auth_oidc/secretexpiryrecipients', + get_string('secretexpiryrecipients', 'auth_oidc'), + get_string('secretexpiryrecipients_help', 'auth_oidc'), + '', + PARAM_TEXT + )); + } + + // Conditional display: show secret field only when auth method is "secret". + $applicationsettings->hide_if( + 'auth_oidc/clientsecret', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_SECRET + ); + + // Conditional display: show certificate fields only when auth method is "certificate". + $applicationsettings->hide_if( + 'auth_oidc/clientcertsource', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_CERTIFICATE + ); + $applicationsettings->hide_if( + 'auth_oidc/clientcertpassphrase', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_CERTIFICATE + ); + + // Conditional display: certificate text fields only when cert source is "text". + $applicationsettings->hide_if( + 'auth_oidc/clientprivatekey', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_CERTIFICATE + ); + $applicationsettings->hide_if( + 'auth_oidc/clientprivatekey', + 'auth_oidc/clientcertsource', + 'neq', + AUTH_OIDC_AUTH_CERT_SOURCE_TEXT + ); + $applicationsettings->hide_if( + 'auth_oidc/clientcert', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_CERTIFICATE + ); + $applicationsettings->hide_if( + 'auth_oidc/clientcert', + 'auth_oidc/clientcertsource', + 'neq', + AUTH_OIDC_AUTH_CERT_SOURCE_TEXT + ); + + // Conditional display: certificate file fields only when cert source is "file". + $applicationsettings->hide_if( + 'auth_oidc/clientprivatekeyfile', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_CERTIFICATE + ); + $applicationsettings->hide_if( + 'auth_oidc/clientprivatekeyfile', + 'auth_oidc/clientcertsource', + 'neq', + AUTH_OIDC_AUTH_CERT_SOURCE_FILE + ); + $applicationsettings->hide_if( + 'auth_oidc/clientcertfile', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_CERTIFICATE + ); + $applicationsettings->hide_if( + 'auth_oidc/clientcertfile', + 'auth_oidc/clientcertsource', + 'neq', + AUTH_OIDC_AUTH_CERT_SOURCE_FILE + ); + + // Conditional display: secret expiry recipients only for non-OTHER Microsoft IdP using secret auth. + if (auth_oidc_is_local_365_installed()) { + $applicationsettings->hide_if( + 'auth_oidc/secretexpiryrecipients', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_SECRET + ); + $applicationsettings->hide_if( + 'auth_oidc/secretexpiryrecipients', + 'auth_oidc/idptype', + 'eq', + AUTH_OIDC_IDP_TYPE_OTHER + ); + } + + $ADMIN->add('oidcfolder', $applicationsettings); + $idptype = get_config('auth_oidc', 'idptype'); if ($idptype) { - // Binding username claim page. - $ADMIN->add('oidcfolder', new admin_externalpage( + // Binding username claim settings page. + $bindingusernamesettings = new admin_settingpage( 'auth_oidc_binding_username_claim', - get_string('settings_page_binding_username_claim', 'auth_oidc'), - new moodle_url('/auth/oidc/binding_username_claim.php') + get_string('settings_page_binding_username_claim', 'auth_oidc') + ); + + // Add navigation tabs. + $bindingusernamesettings->add(new admin_setting_heading( + 'auth_oidc_binding_username_claim_nav', + '', + auth_oidc_get_settings_nav_html('auth_oidc_binding_username_claim') )); - // Change binding username claim tool page. - $ADMIN->add('oidcfolder', new admin_externalpage( - 'auth_oidc_change_binding_username_claim_tool', - get_string('settings_page_change_binding_username_claim_tool', 'auth_oidc'), - new moodle_url('/auth/oidc/change_binding_username_claim_tool.php') + // Determine options and description based on IdP type and user sync state. + switch ($idptype) { + case AUTH_OIDC_IDP_TYPE_OTHER: + $bindingclaimdesc = 'binding_username_claim_help_non_ms'; + $bindingusernameoptions = [ + 'auto' => get_string('binding_username_auto', 'auth_oidc'), + 'preferred_username' => 'preferred_username', + 'email' => 'email', + 'unique_name' => 'unique_name', + 'sub' => 'sub', + 'samaccountname' => 'samaccountname', + 'custom' => get_string('binding_username_custom', 'auth_oidc'), + ]; + break; + case AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM: + case AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID: + if (auth_oidc_is_local_365_installed() && auth_oidc_is_user_sync_enabled()) { + $bindingclaimdesc = 'binding_username_claim_help_ms_with_user_sync'; + $bindingusernameoptions = [ + 'auto' => get_string('binding_username_auto', 'auth_oidc'), + 'email' => 'email', + 'upn' => 'upn', + 'oid' => 'oid', + 'samaccountname' => 'samaccountname', + ]; + } else { + $bindingclaimdesc = 'binding_username_claim_help_ms_no_user_sync'; + $bindingusernameoptions = [ + 'auto' => get_string('binding_username_auto', 'auth_oidc'), + 'preferred_username' => 'preferred_username', + 'email' => 'email', + 'upn' => 'upn', + 'unique_name' => 'unique_name', + 'oid' => 'oid', + 'sub' => 'sub', + 'samaccountname' => 'samaccountname', + 'custom' => get_string('binding_username_custom', 'auth_oidc'), + ]; + } + break; + default: + $bindingclaimdesc = 'binding_username_claim_help_ms_no_user_sync'; + $bindingusernameoptions = [ + 'auto' => get_string('binding_username_auto', 'auth_oidc'), + 'preferred_username' => 'preferred_username', + 'email' => 'email', + 'upn' => 'upn', + 'unique_name' => 'unique_name', + 'sub' => 'sub', + 'oid' => 'oid', + 'samaccountname' => 'samaccountname', + 'custom' => get_string('binding_username_custom', 'auth_oidc'), + ]; + } + + $bindingusernamesettings->add(new admin_setting_configselect( + 'auth_oidc/bindingusernameclaim', + get_string('bindingusernameclaim', 'auth_oidc'), + get_string($bindingclaimdesc, 'auth_oidc'), + 'auto', + $bindingusernameoptions )); + + // Custom claim name (only when the 'custom' option is available for the current IdP type). + if (array_key_exists('custom', $bindingusernameoptions)) { + $bindingusernamesettings->add( + new admin_setting_configtext( + 'auth_oidc/customclaimname', + get_string('customclaimname', 'auth_oidc'), + get_string('customclaimname_description', 'auth_oidc'), + '', + PARAM_TEXT + ) + ); + $bindingusernamesettings->hide_if( + 'auth_oidc/customclaimname', + 'auth_oidc/bindingusernameclaim', + 'neq', + 'custom' + ); + } + + $toolurl = new url('/auth/oidc/change_binding_username_claim_tool.php'); + $bindingusernamesettings->add(new admin_setting_heading( + 'auth_oidc_binding_username_claim_tool_link', + '', + get_string('binding_username_claim_tool_link_desc', 'auth_oidc', $toolurl->out()) + )); + + $ADMIN->add('oidcfolder', $bindingusernamesettings); + + // Change binding username claim tool page (bulk migration tool, not a settings page). + $ADMIN->add( + 'oidcfolder', + new admin_externalpage( + 'auth_oidc_change_binding_username_claim_tool', + get_string('settings_page_change_binding_username_claim_tool', 'auth_oidc'), + new url('/auth/oidc/change_binding_username_claim_tool.php') + ) + ); } // Other settings page and its settings. - $settings = new admin_settingpage($section, get_string('settings_page_other_settings', 'auth_oidc')); + $settings = new admin_settingpage('auth_oidc_other_settings', get_string('settings_page_other_settings', 'auth_oidc')); - // Basic heading. + // Add navigation tabs. $settings->add(new admin_setting_heading( - 'auth_oidc/basic_heading', - get_string('heading_basic', 'auth_oidc'), - get_string('heading_basic_desc', 'auth_oidc') - )); - - // Redirect URI. - $settings->add(new auth_oidc_admin_setting_redirecturi( - 'auth_oidc/redirecturi', - get_string('cfg_redirecturi_key', 'auth_oidc'), - get_string('cfg_redirecturi_desc', 'auth_oidc'), - utils::get_redirecturl() - )); - - // Link to authentication options. - $authenticationconfigurationurl = new moodle_url('/auth/oidc/manageapplication.php'); - $settings->add(new admin_setting_description( - 'auth_oidc/authenticationlink', - get_string('settings_page_application', 'auth_oidc'), - get_string('cfg_authenticationlink_desc', 'auth_oidc', $authenticationconfigurationurl->out()) + 'auth_oidc_other_settings_nav', + '', + auth_oidc_get_settings_nav_html('auth_oidc_other_settings') )); // Additional options heading. - $settings->add(new admin_setting_heading( - 'auth_oidc/additional_options_heading', - get_string('heading_additional_options', 'auth_oidc'), - get_string('heading_additional_options_desc', 'auth_oidc') - )); + $settings->add( + new admin_setting_heading( + 'auth_oidc/additional_options_heading', + get_string('heading_additional_options', 'auth_oidc'), + get_string('heading_additional_options_desc', 'auth_oidc') + ) + ); // Force redirect. - $settings->add(new admin_setting_configcheckbox( - 'auth_oidc/forceredirect', - get_string('cfg_forceredirect_key', 'auth_oidc'), - get_string('cfg_forceredirect_desc', 'auth_oidc'), - 0 - )); + $settings->add( + new admin_setting_configcheckbox( + 'auth_oidc/forceredirect', + get_string('cfg_forceredirect_key', 'auth_oidc'), + get_string('cfg_forceredirect_desc', 'auth_oidc'), + 0 + ) + ); // Silent login mode. - $forceloginconfigurl = new moodle_url('/admin/settings.php', ['section' => 'sitepolicies']); - $settings->add(new admin_setting_configcheckbox( - 'auth_oidc/silentloginmode', - get_string('cfg_silentloginmode_key', 'auth_oidc'), - get_string('cfg_silentloginmode_desc', 'auth_oidc', $forceloginconfigurl->out(false)), - 0 - )); + $forceloginconfigurl = new url('/admin/settings.php', ['section' => 'sitepolicies']); + $settings->add( + new admin_setting_configcheckbox( + 'auth_oidc/silentloginmode', + get_string('cfg_silentloginmode_key', 'auth_oidc'), + get_string('cfg_silentloginmode_desc', 'auth_oidc', $forceloginconfigurl->out(false)), + 0 + ) + ); // Auto-append. - $settings->add(new admin_setting_configtext( - 'auth_oidc/autoappend', - get_string('cfg_autoappend_key', 'auth_oidc'), - get_string('cfg_autoappend_desc', 'auth_oidc'), - '', - PARAM_TEXT - )); + $settings->add( + new admin_setting_configtext( + 'auth_oidc/autoappend', + get_string('cfg_autoappend_key', 'auth_oidc'), + get_string('cfg_autoappend_desc', 'auth_oidc'), + '', + PARAM_TEXT + ) + ); // Domain hint. - $settings->add(new admin_setting_configtext( - 'auth_oidc/domainhint', - get_string('cfg_domainhint_key', 'auth_oidc'), - get_string('cfg_domainhint_desc', 'auth_oidc'), - '', - PARAM_TEXT - )); + $settings->add( + new admin_setting_configtext( + 'auth_oidc/domainhint', + get_string('cfg_domainhint_key', 'auth_oidc'), + get_string('cfg_domainhint_desc', 'auth_oidc'), + '', + PARAM_TEXT + ) + ); // Login flow. - $settings->add(new auth_oidc_admin_setting_loginflow( - 'auth_oidc/loginflow', - get_string('cfg_loginflow_key', 'auth_oidc'), - '', - 'authcode' - )); + $settings->add( + new auth_oidc_admin_setting_loginflow( + 'auth_oidc/loginflow', + get_string('cfg_loginflow_key', 'auth_oidc'), + '', + 'authcode' + ) + ); // User restrictions heading. - $settings->add(new admin_setting_heading( - 'auth_oidc/user_restrictions_heading', - get_string('heading_user_restrictions', 'auth_oidc'), - get_string('heading_user_restrictions_desc', 'auth_oidc') - )); + $settings->add( + new admin_setting_heading( + 'auth_oidc/user_restrictions_heading', + get_string('heading_user_restrictions', 'auth_oidc'), + get_string('heading_user_restrictions_desc', 'auth_oidc') + ) + ); // User restrictions. - $settings->add(new admin_setting_configtextarea( - 'auth_oidc/userrestrictions', - get_string('cfg_userrestrictions_key', 'auth_oidc'), - get_string('cfg_userrestrictions_desc', 'auth_oidc'), - '', - PARAM_TEXT - )); + $settings->add( + new admin_setting_configtextarea( + 'auth_oidc/userrestrictions', + get_string('cfg_userrestrictions_key', 'auth_oidc'), + get_string('cfg_userrestrictions_desc', 'auth_oidc'), + '', + PARAM_TEXT + ) + ); // User restrictions case sensitivity. - $settings->add(new admin_setting_configcheckbox( - 'auth_oidc/userrestrictionscasesensitive', - get_string('cfg_userrestrictionscasesensitive_key', 'auth_oidc'), - get_string('cfg_userrestrictionscasesensitive_desc', 'auth_oidc'), - '1' - )); + $settings->add( + new admin_setting_configcheckbox( + 'auth_oidc/userrestrictionscasesensitive', + get_string('cfg_userrestrictionscasesensitive_key', 'auth_oidc'), + get_string('cfg_userrestrictionscasesensitive_desc', 'auth_oidc'), + '1' + ) + ); // Sign out integration heading. - $settings->add(new admin_setting_heading( - 'auth_oidc/sign_out_heading', - get_string('heading_sign_out', 'auth_oidc'), - get_string('heading_sign_out_desc', 'auth_oidc') - )); + $settings->add( + new admin_setting_heading( + 'auth_oidc/sign_out_heading', + get_string('heading_sign_out', 'auth_oidc'), + get_string('heading_sign_out_desc', 'auth_oidc') + ) + ); // Single sign out from Moodle to IdP. - $settings->add(new admin_setting_configcheckbox( - 'auth_oidc/single_sign_off', - get_string('cfg_signoffintegration_key', 'auth_oidc'), - get_string('cfg_signoffintegration_desc', 'auth_oidc', $CFG->wwwroot), - '0' - )); + $settings->add( + new admin_setting_configcheckbox( + 'auth_oidc/single_sign_off', + get_string('cfg_signoffintegration_key', 'auth_oidc'), + get_string('cfg_signoffintegration_desc', 'auth_oidc', $CFG->wwwroot), + '0' + ) + ); // IdP logout endpoint. - $settings->add(new admin_setting_configtext( - 'auth_oidc/logouturi', - get_string('cfg_logoutendpoint_key', 'auth_oidc'), - get_string('cfg_logoutendpoint_desc', 'auth_oidc'), - 'https://login.microsoftonline.com/organizations/oauth2/logout', - PARAM_URL - )); + $settings->add( + new admin_setting_configtext( + 'auth_oidc/logouturi', + get_string('cfg_logoutendpoint_key', 'auth_oidc'), + get_string('cfg_logoutendpoint_desc', 'auth_oidc'), + 'https://login.microsoftonline.com/organizations/oauth2/logout', + PARAM_URL + ) + ); + + $settings->hide_if('auth_oidc/logouturi', 'auth_oidc/single_sign_off', 'notchecked'); // Front channel logout URL. - $settings->add(new auth_oidc_admin_setting_redirecturi( - 'auth_oidc/logoutendpoint', - get_string('cfg_frontchannellogouturl_key', 'auth_oidc'), - get_string('cfg_frontchannellogouturl_desc', 'auth_oidc'), - utils::get_frontchannellogouturl() - )); + $settings->add( + new auth_oidc_admin_setting_redirecturi( + 'auth_oidc/logoutendpoint', + get_string('cfg_frontchannellogouturl_key', 'auth_oidc'), + get_string('cfg_frontchannellogouturl_desc', 'auth_oidc'), + utils::get_frontchannellogouturl() + ) + ); // Display heading. - $settings->add(new admin_setting_heading( - 'auth_oidc/display_heading', - get_string('heading_display', 'auth_oidc'), - get_string('heading_display_desc', 'auth_oidc') - )); + $settings->add( + new admin_setting_heading( + 'auth_oidc/display_heading', + get_string('heading_display', 'auth_oidc'), + get_string('heading_display_desc', 'auth_oidc') + ) + ); // Provider Name (opname). - $settings->add(new admin_setting_configtext( - 'auth_oidc/opname', - get_string('cfg_opname_key', 'auth_oidc'), - get_string('cfg_opname_desc', 'auth_oidc'), - get_string('pluginname', 'auth_oidc'), - PARAM_TEXT + $settings->add( + new admin_setting_configtext( + 'auth_oidc/opname', + get_string('cfg_opname_key', 'auth_oidc'), + get_string('cfg_opname_desc', 'auth_oidc'), + get_string('pluginname', 'auth_oidc'), + PARAM_TEXT + ) + ); + + $settings->add(new admin_setting_configcheckbox( + 'auth_oidc/set_pix', + get_string('cfg_set_pix_key', 'auth_oidc'), + get_string('cfg_set_pix_desc', 'auth_oidc'), + '1' )); // Icon. @@ -288,13 +754,15 @@ 'component' => 'moodle', ], ]; - $settings->add(new auth_oidc_admin_setting_iconselect( - 'auth_oidc/icon', - get_string('cfg_icon_key', 'auth_oidc'), - get_string('cfg_icon_desc', 'auth_oidc'), - 'auth_oidc:o365', - $icons - )); + $settings->add( + new auth_oidc_admin_setting_iconselect( + 'auth_oidc/icon', + get_string('cfg_icon_key', 'auth_oidc'), + get_string('cfg_icon_desc', 'auth_oidc'), + 'auth_oidc:o365', + $icons + ) + ); // Custom icon. $configkey = new lang_string('cfg_customicon_key', 'auth_oidc'); @@ -310,32 +778,60 @@ $customiconsetting->set_updatedcallback('auth_oidc_initialize_customicon'); $settings->add($customiconsetting); + $settings->hide_if('auth_oidc/icon', 'auth_oidc/set_pix', 'notchecked'); + $settings->hide_if('auth_oidc/customicon', 'auth_oidc/set_pix', 'notchecked'); + // Debugging heading. - $settings->add(new admin_setting_heading( - 'auth_oidc/debugging_heading', - get_string('heading_debugging', 'auth_oidc'), - get_string('heading_debugging_desc', 'auth_oidc') - )); + $settings->add( + new admin_setting_heading( + 'auth_oidc/debugging_heading', + get_string('heading_debugging', 'auth_oidc'), + get_string('heading_debugging_desc', 'auth_oidc') + ) + ); // Record debugging messages. - $settings->add(new admin_setting_configcheckbox( - 'auth_oidc/debugmode', - get_string('cfg_debugmode_key', 'auth_oidc'), - get_string('cfg_debugmode_desc', 'auth_oidc'), - '0' - )); + $settings->add( + new admin_setting_configcheckbox( + 'auth_oidc/debugmode', + get_string('cfg_debugmode_key', 'auth_oidc'), + get_string('cfg_debugmode_desc', 'auth_oidc'), + '0' + ) + ); + + // Tools heading. + $cleanupurl = new \core\url('/auth/oidc/cleanupoidctokens.php'); + $settings->add( + new admin_setting_heading( + 'auth_oidc/tools_heading', + get_string('heading_tools', 'auth_oidc'), + get_string('cleanup_oidc_tokens_link_desc', 'auth_oidc', $cleanupurl->out()) + ) + ); $ADMIN->add('oidcfolder', $settings); // Cleanup OIDC tokens page. - $ADMIN->add('oidcfolder', new admin_externalpage( - 'auth_oidc_cleanup_oidc_tokens', - get_string('settings_page_cleanup_oidc_tokens', 'auth_oidc'), - new moodle_url('/auth/oidc/cleanupoidctokens.php') - )); + $ADMIN->add( + 'oidcfolder', + new admin_externalpage( + 'auth_oidc_cleanup_oidc_tokens', + get_string('settings_page_cleanup_oidc_tokens', 'auth_oidc'), + new url('/auth/oidc/cleanupoidctokens.php') + ) + ); // Other settings page and its settings. $fieldmappingspage = new admin_settingpage('auth_oidc_field_mapping', get_string('settings_page_field_mapping', 'auth_oidc')); + + // Add navigation tabs. + $fieldmappingspage->add(new admin_setting_heading( + 'auth_oidc_field_mapping_nav', + '', + auth_oidc_get_settings_nav_html('auth_oidc_field_mapping') + )); + $ADMIN->add('oidcfolder', $fieldmappingspage); // Display locking / mapping of profile fields. diff --git a/tests/observers_test.php b/tests/observers_test.php index dc942e27a..5333054a1 100644 --- a/tests/observers_test.php +++ b/tests/observers_test.php @@ -17,6 +17,7 @@ namespace auth_oidc; use advanced_testcase; +use core\context\system; use core\event\user_deleted; /** @@ -76,7 +77,7 @@ public function test_token_is_deleted_when_user_is_deleted(): void { $event = user_deleted::create([ 'objectid' => $user->id, 'relateduserid' => $user->id, - 'context' => \context_system::instance(), + 'context' => system::instance(), 'other' => [ 'username' => $user->username, 'email' => $user->email, @@ -115,7 +116,7 @@ public function test_deletion_succeeds_when_user_has_no_tokens(): void { $event = user_deleted::create([ 'objectid' => $user->id, 'relateduserid' => $user->id, - 'context' => \context_system::instance(), + 'context' => system::instance(), 'other' => [ 'username' => $user->username, 'email' => $user->email, diff --git a/tests/privacy_provider_test.php b/tests/privacy_provider_test.php index 8e3d56b18..fd4e22e3b 100644 --- a/tests/privacy_provider_test.php +++ b/tests/privacy_provider_test.php @@ -26,6 +26,9 @@ namespace auth_oidc; use auth_oidc\privacy\provider; +use core\context\system; +use core\context\user; +use core_privacy\local\request\userlist; /** * Privacy test for auth_oidc @@ -63,7 +66,7 @@ public function test_get_contexts_for_userid(): void { $this->assertCount(1, $contextlist); // Check that a context is returned and is the expected context. - $usercontext = \context_user::instance($user->id); + $usercontext = user::instance($user->id); $this->assertEquals($usercontext->id, $contextlist->get_contextids()[0]); } @@ -78,10 +81,10 @@ public function test_get_users_in_context(): void { $component = 'auth_oidc'; // Create a user. $user = $this->getDataGenerator()->create_user(); - $usercontext = \context_user::instance($user->id); + $usercontext = user::instance($user->id); // The list of users should not return anything yet (related data still haven't been created). - $userlist = new \core_privacy\local\request\userlist($usercontext, $component); + $userlist = new userlist($usercontext, $component); provider::get_users_in_context($userlist); $this->assertCount(0, $userlist); @@ -97,7 +100,7 @@ public function test_get_users_in_context(): void { $this->assertEquals($expected, $actual); // The list of users for system context should not return any users. - $userlist = new \core_privacy\local\request\userlist(\context_system::instance(), $component); + $userlist = new userlist(system::instance(), $component); provider::get_users_in_context($userlist); $this->assertCount(0, $userlist); } @@ -113,7 +116,7 @@ public function test_export_user_data(): void { $tokenrecord = self::create_token($user->id); $prevloginrecord = self::create_prevlogin($user->id); - $usercontext = \context_user::instance($user->id); + $usercontext = user::instance($user->id); $writer = \core_privacy\local\request\writer::with_context($usercontext); $this->assertFalse($writer->has_any_data()); @@ -148,7 +151,7 @@ public function test_delete_data_for_all_users_in_context(): void { $user1 = $this->getDataGenerator()->create_user(); self::create_token($user1->id); self::create_prevlogin($user1->id); - $user1context = \context_user::instance($user1->id); + $user1context = user::instance($user1->id); $user2 = $this->getDataGenerator()->create_user(); self::create_token($user2->id); @@ -181,7 +184,7 @@ public function test_delete_data_for_user(): void { $user1 = $this->getDataGenerator()->create_user(); self::create_token($user1->id); self::create_prevlogin($user1->id); - $user1context = \context_user::instance($user1->id); + $user1context = user::instance($user1->id); $user2 = $this->getDataGenerator()->create_user(); self::create_token($user2->id); @@ -214,18 +217,18 @@ public function test_delete_data_for_users(): void { $component = 'auth_oidc'; // Create user1. $user1 = $this->getDataGenerator()->create_user(); - $usercontext1 = \context_user::instance($user1->id); + $usercontext1 = user::instance($user1->id); self::create_token($user1->id); self::create_prevlogin($user1->id); // Create user2. $user2 = $this->getDataGenerator()->create_user(); - $usercontext2 = \context_user::instance($user2->id); + $usercontext2 = user::instance($user2->id); self::create_token($user2->id); self::create_prevlogin($user2->id); // The list of users for usercontext1 should return user1. - $userlist1 = new \core_privacy\local\request\userlist($usercontext1, $component); + $userlist1 = new userlist($usercontext1, $component); provider::get_users_in_context($userlist1); $this->assertCount(1, $userlist1); $expected = [$user1->id]; @@ -233,7 +236,7 @@ public function test_delete_data_for_users(): void { $this->assertEquals($expected, $actual); // The list of users for usercontext2 should return user2. - $userlist2 = new \core_privacy\local\request\userlist($usercontext2, $component); + $userlist2 = new userlist($usercontext2, $component); provider::get_users_in_context($userlist2); $this->assertCount(1, $userlist2); $expected = [$user2->id]; @@ -247,22 +250,22 @@ public function test_delete_data_for_users(): void { provider::delete_data_for_users($approvedlist); // Re-fetch users in usercontext1 - The user list should now be empty. - $userlist1 = new \core_privacy\local\request\userlist($usercontext1, $component); + $userlist1 = new userlist($usercontext1, $component); provider::get_users_in_context($userlist1); $this->assertCount(0, $userlist1); // Re-fetch users in usercontext2 - The user list should not be empty (user2). - $userlist2 = new \core_privacy\local\request\userlist($usercontext2, $component); + $userlist2 = new userlist($usercontext2, $component); provider::get_users_in_context($userlist2); $this->assertCount(1, $userlist2); // User data should be only removed in the user context. - $systemcontext = \context_system::instance(); + $systemcontext = system::instance(); // Add userlist2 to the approved user list in the system context. $approvedlist = new \core_privacy\local\request\approved_userlist($systemcontext, $component, $userlist2->get_userids()); // Delete user1 data using delete_data_for_user. provider::delete_data_for_users($approvedlist); // Re-fetch users in usercontext2 - The user list should not be empty (user2). - $userlist2 = new \core_privacy\local\request\userlist($usercontext2, $component); + $userlist2 = new userlist($usercontext2, $component); provider::get_users_in_context($userlist2); $this->assertCount(1, $userlist2); } @@ -277,11 +280,11 @@ public function test_delete_data_for_users(): void { private static function create_token(int $userid): \stdClass { global $DB; $record = new \stdClass(); - $record->oidcuniqid = "user@example.com"; - $record->username = "user@example.com"; + $record->oidcuniqid = "user{$userid}@example.com"; + $record->username = "user{$userid}@example.com"; $record->userid = $userid; - $record->oidcusername = "user@example.com"; - $record->useridentifier = "user@example.com"; + $record->oidcusername = "user{$userid}@example.com"; + $record->useridentifier = "user{$userid}@example.com"; $record->scope = "All"; $record->tokenresource = "https://graph.microsoft.com"; $record->authcode = "authcode123"; diff --git a/tests/task/cleanup_oidc_sid_test.php b/tests/task/cleanup_oidc_sid_test.php index 040c30f03..9a49b8688 100644 --- a/tests/task/cleanup_oidc_sid_test.php +++ b/tests/task/cleanup_oidc_sid_test.php @@ -48,14 +48,18 @@ public function test_sids_older_than_yesterday_are_deleted(): void { // Create a test user to own the SID records. $user = $this->getDataGenerator()->create_user(); - // Calculate cutoff time once to avoid timing issues if test runs slowly. - // This matches what the cleanup task will calculate during execute(). - $cutofftime = strtotime('-1 day'); + // Use a fixed reference time to avoid timing race conditions. + // The cleanup task will calculate strtotime('-1 day') at execution time, which may differ slightly + // from when we calculate it here. Use a large safety margin to ensure records fall clearly on one side. + $now = time(); + $cutofftime = strtotime('-1 day', $now); - // Create timestamps relative to the cutoff time. - $twodaysago = $cutofftime - DAYSECS; // Older than cutoff, should be deleted. - $yesterday = $cutofftime; // Exactly at cutoff, should be kept (< operator). - $today = time(); // Newer than cutoff, should be kept. + // Create timestamps with clear margins to avoid boundary conditions. + // Add a 5-minute buffer before and after the cutoff to account for execution time variance. + $twodaysago = $cutofftime - DAYSECS; // Well before cutoff, will be deleted. + $beforecutoff = $cutofftime - (MINSECS * 5); // 5 minutes before cutoff, will be deleted. + $aftercutoff = $cutofftime + (MINSECS * 5); // 5 minutes after cutoff, will be kept. + $muchlater = $now; // Current time, will be kept. // Create entries in auth_oidc_sid with unique SIDs. $entry1id = $DB->insert_record( @@ -64,15 +68,15 @@ public function test_sids_older_than_yesterday_are_deleted(): void { ); $entry2id = $DB->insert_record( 'auth_oidc_sid', - ['userid' => $user->id, 'sid' => 'sid_old_2', 'timecreated' => ($twodaysago - 1000)], + ['userid' => $user->id, 'sid' => 'sid_old_2', 'timecreated' => $beforecutoff], ); $entry3id = $DB->insert_record( 'auth_oidc_sid', - ['userid' => $user->id, 'sid' => 'sid_new_1', 'timecreated' => $today], + ['userid' => $user->id, 'sid' => 'sid_new_1', 'timecreated' => $muchlater], ); $entry4id = $DB->insert_record( 'auth_oidc_sid', - ['userid' => $user->id, 'sid' => 'sid_boundary', 'timecreated' => $yesterday], + ['userid' => $user->id, 'sid' => 'sid_new_2', 'timecreated' => $aftercutoff], ); $cleanup = new cleanup_oidc_sid(); diff --git a/ucp.php b/ucp.php index b049e865a..8b0abb15d 100644 --- a/ucp.php +++ b/ucp.php @@ -23,6 +23,9 @@ * @copyright (C) 2014 onwards Microsoft, Inc. (http://microsoft.com/) */ +use core\context\system; +use core\url; + require_once(__DIR__ . '/../../config.php'); require_once(__DIR__ . '/auth.php'); require_once(__DIR__ . '/lib.php'); @@ -59,8 +62,7 @@ } } else { $PAGE->set_url('/auth/oidc/ucp.php'); - $usercontext = \context_user::instance($USER->id); - $PAGE->set_context(\context_system::instance()); + $PAGE->set_context(system::instance()); $PAGE->set_pagelayout('standard'); $USER->editing = false; $authconfig = get_config('auth_oidc'); @@ -88,7 +90,7 @@ echo \html_writer::tag('h4', get_string('ucp_status_enabled', 'auth_oidc'), ['class' => 'notifysuccess']); if (is_enabled_auth('manual') === true) { if (auth_oidc_connectioncapability($USER->id, 'disconnect')) { - $connectlinkuri = new \moodle_url('/auth/oidc/ucp.php', ['action' => 'disconnectlogin']); + $connectlinkuri = new url('/auth/oidc/ucp.php', ['action' => 'disconnectlogin']); $strdisconnect = get_string('ucp_login_stop', 'auth_oidc', $opname); $linkhtml = \html_writer::link($connectlinkuri, $strdisconnect); echo \html_writer::tag('h5', $linkhtml); @@ -98,7 +100,7 @@ } else { echo \html_writer::tag('h4', get_string('ucp_status_disabled', 'auth_oidc'), ['class' => 'notifyproblem']); if (auth_oidc_connectioncapability($USER->id, 'connect')) { - $connectlinkuri = new \moodle_url('/auth/oidc/ucp.php', ['action' => 'connectlogin']); + $connectlinkuri = new url('/auth/oidc/ucp.php', ['action' => 'connectlogin']); $linkhtml = \html_writer::link($connectlinkuri, get_string('ucp_login_start', 'auth_oidc', $opname)); echo \html_writer::tag('h5', $linkhtml); echo \html_writer::span(get_string('ucp_login_start_desc', 'auth_oidc', $opname)); diff --git a/version.php b/version.php index 806da8749..95af4f9aa 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025100601; +$plugin->version = 2025100602; $plugin->requires = 2025100600; -$plugin->release = '5.1.1'; +$plugin->release = '5.1.2'; $plugin->component = 'auth_oidc'; $plugin->maturity = MATURITY_STABLE;