From 47c6484ed64d7155465034a90f0d01fde74731ae Mon Sep 17 00:00:00 2001 From: theqoocjil Date: Sat, 23 May 2026 13:10:46 +0300 Subject: [PATCH 01/36] Populate iconurl with a real URL when $showicon is true --- classes/loginflow/authcode.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..45000ac8f 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -36,7 +36,6 @@ use core_user; use moodle_exception; use core\url; -use pix_icon; use stdClass; defined('MOODLE_INTERNAL') || die(); @@ -64,8 +63,9 @@ public function loginpage_idp_list($wantsurl) { 'name' => strip_tags(format_text($this->config->opname)), ]; if ($showicon) { + global $OUTPUT; if (!empty($this->config->customicon)) { - $iconvalue = new pix_icon('0/customicon', get_string('pluginname', 'auth_oidc'), 'auth_oidc'); + $idpentry['iconurl'] = $OUTPUT->image_url('0/customicon', 'auth_oidc')->out(false); } else { $icon = (!empty($this->config->icon)) ? $this->config->icon : 'auth_oidc:o365'; $icon = explode(':', $icon); @@ -75,9 +75,8 @@ public function loginpage_idp_list($wantsurl) { $iconcomponent = 'auth_oidc'; $iconname = 'o365'; } - $iconvalue = new pix_icon($iconname, get_string('pluginname', 'auth_oidc'), $iconcomponent); + $idpentry['iconurl'] = $OUTPUT->image_url($iconname, $iconcomponent)->out(false); } - $idpentry['icon'] = $iconvalue; } return [$idpentry]; } From 06cfad99fe9375ff028e2e981607206242c882ee Mon Sep 17 00:00:00 2001 From: theqoocjil Date: Mon, 25 May 2026 15:09:05 +0300 Subject: [PATCH 02/36] Repeated the field display settings --- manageapplication.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/manageapplication.php b/manageapplication.php index aac8d2673..5456170ea 100644 --- a/manageapplication.php +++ b/manageapplication.php @@ -112,7 +112,9 @@ switch ($fromform->clientauthmethod) { case AUTH_OIDC_AUTH_METHOD_SECRET: $configstosave[] = 'clientsecret'; - $configstosave[] = 'secretexpiryrecipients'; + if ($fromform->idptype != AUTH_OIDC_IDP_TYPE_OTHER && auth_oidc_is_local_365_installed()) { + $configstosave[] = 'secretexpiryrecipients'; + } break; case AUTH_OIDC_AUTH_METHOD_CERTIFICATE: $configstosave[] = 'clientcertsource'; From 04c5da81cf7ecbe3647b833d777d83d72188f8bc Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Thu, 4 Jun 2026 17:38:20 +0100 Subject: [PATCH 03/36] Simplify secretexpiryrecipients guard using isset() instead of explicit conditions --- manageapplication.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manageapplication.php b/manageapplication.php index 5456170ea..84c2cb7a1 100644 --- a/manageapplication.php +++ b/manageapplication.php @@ -112,7 +112,7 @@ switch ($fromform->clientauthmethod) { case AUTH_OIDC_AUTH_METHOD_SECRET: $configstosave[] = 'clientsecret'; - if ($fromform->idptype != AUTH_OIDC_IDP_TYPE_OTHER && auth_oidc_is_local_365_installed()) { + if (isset($fromform->secretexpiryrecipients)) { $configstosave[] = 'secretexpiryrecipients'; } break; From 994ca5d9414e16e86a887cb4b4e9103738cd567a Mon Sep 17 00:00:00 2001 From: Patryk Mroczko Date: Mon, 20 Jul 2026 09:06:05 +0200 Subject: [PATCH 04/36] Fix XSS in cleanupoidctokens via unescaped OIDC identifier fields --- cleanupoidctokens.php | 6 +++--- lib.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cleanupoidctokens.php b/cleanupoidctokens.php index 604f56f10..c7cc74c75 100644 --- a/cleanupoidctokens.php +++ b/cleanupoidctokens.php @@ -89,9 +89,9 @@ foreach ($tokenstoclean as $item) { $table->data[] = [ $item->id, - $item->oidcusername, - $item->useridentifier, - $item->oidcuniqueid, + s($item->oidcusername), + s($item->useridentifier), + s($item->oidcuniqueid), $item->matchingstatus, $item->details, $item->action, diff --git a/lib.php b/lib.php index 5d13a5ae0..518adca63 100644 --- a/lib.php +++ b/lib.php @@ -307,7 +307,7 @@ function auth_oidc_get_tokens_with_empty_ids() { $item = new stdClass(); $item->id = $record->id; $item->oidcusername = $record->oidcusername; - $item->useriditifier = $record->useridentifier; + $item->useridentifier = $record->useridentifier; $item->moodleusername = $record->username; $item->userid = 0; $item->oidcuniqueid = $record->oidcuniqid; @@ -350,7 +350,7 @@ function auth_oidc_get_tokens_with_mismatched_usernames() { $item->details = get_string( 'mismatched_details', 'auth_oidc', - ['tokenusername' => $record->tokenusername, 'moodleusername' => $record->musername] + ['tokenusername' => s($record->tokenusername), 'moodleusername' => s($record->musername)] ); $deletetokenurl = new url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id]); $item->action = html_writer::link($deletetokenurl, get_string('delete_token_and_reference', 'auth_oidc')); From 39917d8afaed47af47f2248de7e454f1c19d3dbd Mon Sep 17 00:00:00 2001 From: Patryk Mroczko Date: Mon, 20 Jul 2026 10:44:13 +0200 Subject: [PATCH 05/36] Fix CSRF in cleanupoidctokens token deletion by adding sesskey validation --- cleanupoidctokens.php | 1 + lib.php | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cleanupoidctokens.php b/cleanupoidctokens.php index 604f56f10..10220736e 100644 --- a/cleanupoidctokens.php +++ b/cleanupoidctokens.php @@ -56,6 +56,7 @@ $deletetokenid = optional_param('id', 0, PARAM_INT); if ($deletetokenid) { + require_sesskey(); if (array_key_exists($deletetokenid, $tokenstoclean)) { auth_oidc_delete_token($deletetokenid); diff --git a/lib.php b/lib.php index 5d13a5ae0..b2605b5b2 100644 --- a/lib.php +++ b/lib.php @@ -313,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 url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id]); + $deletetokenurl = new url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id, 'sesskey' => sesskey()]); $item->action = html_writer::link($deletetokenurl, get_string('delete_token', 'auth_oidc')); $emptyuseridtokens[$record->id] = $item; @@ -352,7 +352,7 @@ function auth_oidc_get_tokens_with_mismatched_usernames() { 'auth_oidc', ['tokenusername' => $record->tokenusername, 'moodleusername' => $record->musername] ); - $deletetokenurl = new url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id]); + $deletetokenurl = new url('/auth/oidc/cleanupoidctokens.php', ['id' => $record->id, 'sesskey' => sesskey()]); $item->action = html_writer::link($deletetokenurl, get_string('delete_token_and_reference', 'auth_oidc')); $mismatchedtokens[$record->id] = $item; From 48f38fb0c29b94a68b470ca75fb77f51a0578e44 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Tue, 28 Jul 2026 17:06:55 +0100 Subject: [PATCH 06/36] Remove Resource Owner Password Credentials (rocreds) grant from auth_oidc --- .../auth_oidc_admin_setting_loginflow.php | 2 +- classes/loginflow/rocreds.php | 210 ------------------ classes/oidcclient.php | 38 ---- db/upgrade.php | 10 + lang/cs/auth_oidc.php | 2 - lang/de/auth_oidc.php | 2 - lang/en/auth_oidc.php | 2 - lang/es/auth_oidc.php | 2 - lang/fi/auth_oidc.php | 2 - lang/fr/auth_oidc.php | 2 - lang/it/auth_oidc.php | 2 - lang/ja/auth_oidc.php | 2 - lang/nl/auth_oidc.php | 2 - lang/pl/auth_oidc.php | 2 - lang/pt_br/auth_oidc.php | 2 - version.php | 2 +- 16 files changed, 12 insertions(+), 272 deletions(-) delete mode 100644 classes/loginflow/rocreds.php diff --git a/classes/adminsetting/auth_oidc_admin_setting_loginflow.php b/classes/adminsetting/auth_oidc_admin_setting_loginflow.php index 80cb3c172..d2ed5bfaa 100644 --- a/classes/adminsetting/auth_oidc_admin_setting_loginflow.php +++ b/classes/adminsetting/auth_oidc_admin_setting_loginflow.php @@ -30,7 +30,7 @@ */ class auth_oidc_admin_setting_loginflow extends \admin_setting { /** @var array Array of valid login flow types. */ - protected $flowtypes = ['authcode', 'rocreds']; + protected $flowtypes = ['authcode']; /** * Return the setting diff --git a/classes/loginflow/rocreds.php b/classes/loginflow/rocreds.php deleted file mode 100644 index 0152bae25..000000000 --- a/classes/loginflow/rocreds.php +++ /dev/null @@ -1,210 +0,0 @@ -. - -/** - * Resource Owner Password Credentials Grant login flow. - * - * @package auth_oidc - * @author James McQuillan - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @copyright (C) 2014 onwards Microsoft, Inc. (http://microsoft.com/) - */ - -namespace auth_oidc\loginflow; - -use auth_oidc\event\user_created; -use auth_oidc\utils; - -defined('MOODLE_INTERNAL') || die(); - -require_once($CFG->dirroot . '/auth/oidc/lib.php'); - -/** - * Login flow for the oauth2 resource owner credentials grant. - */ -class rocreds extends base { - /** - * Check for an existing user object. - * - * @param string $o356username - * - * @return string If there is an existing user object, return the username associated with it. - * If there is no existing user object, return the original username. - */ - protected function check_objects($o356username) { - global $DB; - - $user = null; - if (auth_oidc_is_local_365_installed()) { - $sql = 'SELECT u.username - FROM {local_o365_objects} obj - JOIN {user} u ON u.id = obj.moodleid - WHERE obj.o365name = ? and obj.type = ?'; - $params = [$o356username, 'user']; - $user = $DB->get_record_sql($sql, $params); - } - - return (!empty($user)) ? $user->username : $o356username; - } - - /** - * Provides a hook into the login page. - * - * @param stdClass $frm Form object. - * @param stdClass $user User object. - * @return bool - */ - public function loginpage_hook(&$frm, &$user) { - global $DB; - - if (empty($frm)) { - $frm = data_submitted(); - } - if (empty($frm)) { - return true; - } - - $username = $frm->username; - $password = $frm->password; - $auth = 'oidc'; - - $username = $this->check_objects($username); - if ($username !== $frm->username) { - $success = $this->user_login($username, $password); - if ($success === true) { - $existinguser = $DB->get_record('user', ['username' => $username]); - if (!empty($existinguser)) { - $user = $existinguser; - return true; - } - } - } - - $autoappend = get_config('auth_oidc', 'autoappend'); - if (empty($autoappend)) { - // If we're not doing autoappend, just let things flow naturally. - return true; - } - - $existinguser = $DB->get_record('user', ['username' => $username]); - if (!empty($existinguser)) { - // We don't want to prevent access to existing accounts. - return true; - } - - $username .= $autoappend; - $success = $this->user_login($username, $password); - if ($success !== true) { - // No o365 user, continue normally. - return false; - } - - $existinguser = $DB->get_record('user', ['username' => $username]); - if (!empty($existinguser)) { - $user = $existinguser; - return true; - } - - // The user is authenticated but user creation may be disabled. - if (!empty($CFG->authpreventaccountcreation)) { - $failurereason = AUTH_LOGIN_UNAUTHORISED; - - // Trigger login failed event. - $event = \core\event\user_login_failed::create([ - 'other' => [ - 'username' => $username, - 'reason' => $failurereason, - ], - ]); - $event->trigger(); - - debugging('[client ' . getremoteaddr() . "] $CFG->wwwroot Unknown user, can not create new accounts: $username " . - $_SERVER['HTTP_USER_AGENT']); - - return false; - } - - $user = create_user_record($username, $password, $auth); - - // Trigger user_created event. - $eventdata = [ - 'objectid' => $user->id, - 'userid' => $user->id, - 'relateduserid' => $user->id, - ]; - $event = user_created::create($eventdata); - $event->trigger(); - - return true; - } - - /** - * This is the primary method that is used by the authenticate_user_login() function in moodlelib.php. - * - * @param string $username The username (with system magic quotes) - * @param string $password The password (with system magic quotes) - * @return bool Authentication success or failure. - */ - public function user_login($username, $password = null) { - global $DB; - - $client = $this->get_oidcclient(); - $authparams = ['code' => '']; - - $oidcusername = $username; - $oidctoken = $DB->get_records('auth_oidc_token', ['username' => $username]); - if (!empty($oidctoken)) { - $oidctoken = array_shift($oidctoken); - if (!empty($oidctoken) && !empty($oidctoken->oidcusername)) { - $oidcusername = $oidctoken->oidcusername; - } - } - - // Make request. - $tokenparams = $client->rocredsrequest($oidcusername, $password); - if (!empty($tokenparams) && isset($tokenparams['token_type']) && $tokenparams['token_type'] === 'Bearer') { - [$oidcuniqid, $idtoken] = $this->process_idtoken($tokenparams['id_token']); - - // Check restrictions. - $passed = $this->checkrestrictions($idtoken); - if ($passed !== true) { - $errstr = 'User prevented from logging in due to restrictions.'; - utils::debug($errstr, __METHOD__, $idtoken); - return false; - } - - $tokenrec = $DB->get_record('auth_oidc_token', ['oidcuniqid' => $oidcuniqid]); - if (!empty($tokenrec)) { - $this->updatetoken($tokenrec->id, $authparams, $tokenparams); - } else { - $originalupn = null; - if (auth_oidc_is_local_365_installed()) { - $apiclient = \local_o365\utils::get_api(); - $userdetails = $apiclient->get_user($oidcuniqid); - if ( - !is_null($userdetails) && isset($userdetails['userPrincipalName']) && - stripos($userdetails['userPrincipalName'], '#EXT#') !== false - ) { - $originalupn = $userdetails['userPrincipalName']; - } - } - $this->createtoken($oidcuniqid, $username, $authparams, $tokenparams, $idtoken, 0, $originalupn); - } - return true; - } - return false; - } -} diff --git a/classes/oidcclient.php b/classes/oidcclient.php index 54bf6b59b..c8d874809 100644 --- a/classes/oidcclient.php +++ b/classes/oidcclient.php @@ -301,44 +301,6 @@ public function adminconsentrequest(array $stateparams = [], array $extraparams redirect($redirecturl); } - /** - * Make a token request using the resource-owner credentials login flow. - * - * @param string $username The resource owner's username. - * @param string $password The resource owner's password. - * @return array Received parameters. - */ - public function rocredsrequest($username, $password) { - if (empty($this->endpoints['token'])) { - throw new moodle_exception('erroroidcclientnotokenendpoint', 'auth_oidc'); - } - - if (strpos($this->endpoints['token'], 'https://') !== 0) { - throw new moodle_exception('erroroidcclientinsecuretokenendpoint', 'auth_oidc'); - } - - $params = [ - 'grant_type' => 'password', - 'username' => $username, - 'password' => $password, - 'scope' => 'openid profile email', - 'client_id' => $this->clientid, - 'client_secret' => $this->clientsecret, - ]; - - if (get_config('auth_oidc', 'idptype') != AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM) { - $params['resource'] = $this->tokenresource; - } - - try { - $returned = $this->httpclient->post($this->endpoints['token'], $params); - return utils::process_json_response($returned, ['token_type' => null, 'id_token' => null]); - } catch (moodle_exception $e) { - utils::debug('Error in rocredsrequest request', __METHOD__, $e->getMessage()); - return false; - } - } - /** * Exchange an authorization code for an access token. * diff --git a/db/upgrade.php b/db/upgrade.php index c4d776684..681e5a0ba 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -586,6 +586,16 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025100601.01, 'auth', 'oidc'); } + if ($oldversion < 2026042000.01) { + // The rocreds (Resource Owner Password Credentials Grant) login flow has been removed. + // Reset any site still configured to use it back to the authcode flow. + if (get_config('auth_oidc', 'loginflow') === 'rocreds') { + set_config('loginflow', 'authcode', 'auth_oidc'); + } + + upgrade_plugin_savepoint(true, 2026042000.01, 'auth', 'oidc'); + } + return true; } diff --git a/lang/cs/auth_oidc.php b/lang/cs/auth_oidc.php index 08e46baf9..80961a9d7 100644 --- a/lang/cs/auth_oidc.php +++ b/lang/cs/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Postup přihlášení'; $string['cfg_loginflow_authcode'] = 'Požadavek na autorizaci'; $string['cfg_loginflow_authcode_desc'] = 'Při použití tohoto postupu uživatel na přihlašovací stránce Moodlu klikne na ikonu poskytovatele identity (viz výše „Název poskytovatele“) a je následně přesměrován na poskytovatele, aby se přihlásil. Po úspěšném přihlášení je uživatel přesměrován zpět do Moodlu, kde proběhne transparentní přihlášení do Moodlu. Toto je nejstandardizovanější bezpečný způsob přihlašování uživatelů.'; -$string['cfg_loginflow_rocreds'] = 'Ověřování uživatelské_jméno/heslo'; -$string['cfg_loginflow_rocreds_desc'] = 'Při použití tohoto postupu uživatel zadá své uživatelské jméno a heslo do přihlašovacího formuláře Moodlu, obdobně jako při ručním přihlášení. Jeho přihlašovací údaje jsou pak na pozadí předány poskytovateli identity, aby bylo získáno ověření. Tento postup je pro uživatele nejtransparentnější, protože nemá žádný přímý kontakt s poskytovatelem identity. Ne všichni poskytovatelé identity ale tento postup podporují.'; $string['cfg_oidcresource_key'] = 'Zdroj'; $string['cfg_oidcresource_desc'] = 'Zdroj OpenID Connect, pro který se odesílá požadavek.'; $string['cfg_oidcscope_key'] = 'Scope'; diff --git a/lang/de/auth_oidc.php b/lang/de/auth_oidc.php index fb3bd3238..404b1e8be 100644 --- a/lang/de/auth_oidc.php +++ b/lang/de/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Anmeldefluss'; $string['cfg_loginflow_authcode'] = 'Autorisierungsanforderung'; $string['cfg_loginflow_authcode_desc'] = 'Mit diesem Fluss klickt der Benutzer auf der Moodle-Anmeldeseite auf den Namen des Identitätsproviders (siehe "Providername" weiter oben) und wird zur Anmeldung zum Provider umgeleitet. Nach erfolgreicher Anmeldung wird der Benutzer zurück zu Moodle umgeleitet, wo die Moodle-Anmeldung transparent durchgeführt wird. Dies ist die am meisten standardisierte und sicherste Möglichkeit der Benutzeranmeldung.'; -$string['cfg_loginflow_rocreds'] = 'Authentifizierung mit Benutzername/Kennwort'; -$string['cfg_loginflow_rocreds_desc'] = 'Mit diesem Fluss gibt der Benutzer wie bei einer manuellen Anmeldung seinen Benutzernamen und sein Kennwort im Moodle-Anmeldeformular ein. Die Anmeldedaten werden dann im Hintergrund zur Authentifizierung an den Identitätsprovider übermittelt. Dieser Fluss ist für den Benutzer am transparentesten, da er keine direkte Interaktion mit dem Identitätsprovider hat. Alle Identitätsprovider unterstützen diesen Fluss.'; $string['cfg_oidcresource_key'] = 'Ressource'; $string['cfg_oidcresource_desc'] = 'Die OpenID Connect-Ressource, für die die Anfrage gesendet wird.'; $string['cfg_oidcscope_key'] = 'Umfang'; diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..04cde6b84 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -141,8 +141,6 @@ $string['cfg_loginflow_key'] = 'Login Flow'; $string['cfg_loginflow_authcode'] = 'Authorization Code Flow (recommended)'; $string['cfg_loginflow_authcode_desc'] = 'Using this flow, the user clicks the name of the IdP (See "Provider Display Name" above) on the Moodle login page and is redirected to the provider to log in. Once successfully logged in, the user is redirected back to Moodle where the Moodle login takes place transparently. This is the most standardized, secure way for the user log in.'; -$string['cfg_loginflow_rocreds'] = 'Resource Owner Password Credentials Grant (deprecated)'; -$string['cfg_loginflow_rocreds_desc'] = 'This login flow is deprecated and will be removed from the plugin soon.
Using this flow, the user enters their username and password into the Moodle login form like they would with a manual login. This will authorize the user with the IdP, but will not create a session on the IdP\'s site. For example, if using Microsoft 365 with OpenID Connect, the user will be logged in to Moodle but not the Microsoft 365 web applications. Using the authorization request is recommended if you want users to be logged in to both Moodle and the IdP. Note that not all IdP support this flow. This option should only be used when other authorization grant types are not available.'; $string['cfg_silentloginmode_key'] = 'Silent Login Mode'; $string['cfg_silentloginmode_desc'] = 'If enabled, Moodle will try to use the active session of a user authenticated to the configured authorization endpoint to log the user in.
To use this feature, the following configurations are required: diff --git a/lang/es/auth_oidc.php b/lang/es/auth_oidc.php index d77020ff8..daf1f70b0 100644 --- a/lang/es/auth_oidc.php +++ b/lang/es/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Flujo de inicio de sesión'; $string['cfg_loginflow_authcode'] = 'Solicitud de autorización'; $string['cfg_loginflow_authcode_desc'] = 'Al utiliza este flujo, el usuario hace clic en el nombre del proveedor de identidad (consulte "Nombre del proveedor" más arriba) en la página de inicio de sesión de Moodle y es redireccionado al proveedor para iniciar sesión. Una vez que haya iniciado sesión correctamente, es redireccionado de vuelta a Moodle, donde se realiza el inicio de sesión de manera transparente. Esta es la forma más segura y estandarizada de inicio de sesión del usuario.'; -$string['cfg_loginflow_rocreds'] = 'Autenticación de nombre de usuario/contraseña'; -$string['cfg_loginflow_rocreds_desc'] = 'Al usar este flujo, el usuario ingresa el nombre de usuario y la contraseña al formulario de inicio de sesión de Moodle como lo haría de manera manual. Luego, las credenciales se pasan al proveedor de identidad en el segundo plano para obtener la autenticación. Este flujo es la forma más transparente para el usuario ya que no posee interacción directa con el proveedor de identidad. Tenga en cuenta que no todos los proveedores de identidad admiten este flujo.'; $string['cfg_oidcresource_key'] = 'Recurso'; $string['cfg_oidcresource_desc'] = 'El recurso de OpenID Connect para el cual enviar la solicitud.'; $string['cfg_oidcscope_key'] = 'Scope'; diff --git a/lang/fi/auth_oidc.php b/lang/fi/auth_oidc.php index 272309fcf..d5a7f8d4d 100644 --- a/lang/fi/auth_oidc.php +++ b/lang/fi/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Kirjautumiskulku'; $string['cfg_loginflow_authcode'] = 'Valtuutuspyyntö'; $string['cfg_loginflow_authcode_desc'] = 'Jos tämä kirjautumiskulku on käytössä, käyttäjä napsauttaa identiteetintarjoajan nimeä (ks. Palveluntarjoajan nimi) Moodlen kirjautumissivulla, jonka jälkeen käyttäjä ohjataan palveluntarjoajan sivulle kirjautumista varten. Jos kirjautuminen onnistuu, käyttäjä ohjataan takaisin Moodleen, jossa Moodle-kirjautuminen tapahtuu läpinäkyvästi. Tämä on standardisoitu ja turvallisin käyttäjien kirjautumismenetelmä.'; -$string['cfg_loginflow_rocreds'] = 'Käyttäjänimen/salasanan todennus'; -$string['cfg_loginflow_rocreds_desc'] = 'Jos tämä kirjautumiskulku on käytössä, käyttäjä kirjautuu Moodleen antamalla käyttäjänimen ja salasanan Moodlen kirjautumislomakkeeseen. Tunnistetiedot välitetään taustalla identiteetintarjoajalle todennusta varten. Tämä kulku on läpinäkyvin käyttäjän kannalta, koska käyttäjä ei ole suoraan tekemisissä identiteetintarjoajan kanssa. Huomaa, että kaikki identiteetintarjoajat eivät tue tätä kulkua.'; $string['cfg_oidcresource_key'] = 'Resurssi'; $string['cfg_oidcresource_desc'] = 'OpenID Connect -resurssi, jota lähetettävä pyyntö koskee.'; $string['cfg_oidcscope_key'] = 'laajuus'; diff --git a/lang/fr/auth_oidc.php b/lang/fr/auth_oidc.php index 885f66c91..08b0e9767 100644 --- a/lang/fr/auth_oidc.php +++ b/lang/fr/auth_oidc.php @@ -68,8 +68,6 @@ $string['cfg_loginflow_key'] = 'Méthode d\'authentification'; $string['cfg_loginflow_authcode'] = 'Demande d\'autorisation (recommandée)'; $string['cfg_loginflow_authcode_desc'] = 'À l\'aide de cette méthode, l\'utilisateur clique sur le fournisseur d\'identité (voir « Nom du fournisseur » ci-dessus) sur la page de connexion Moodle et est redirigé vers le fournisseur pour se connecter. Une fois la connexion réussie, l\'utilisateur est redirigé vers Moodle où la connexion Moodle est effectuée en toute transparence. Il s\'agit pour l\'utilisateur du moyen le plus sécurisé et standardisé pour se connecter.'; -$string['cfg_loginflow_rocreds'] = 'Authentification via nom d\'utilisateur/mot de passe'; -$string['cfg_loginflow_rocreds_desc'] = 'À l\'aide de cette méthode, l\'utilisateur saisit son nom d\'utilisateur et son mot de passe dans le formulaire de connexion Moodle comme il le ferait avec une connexion manuelle. Ses informations d\'identification sont ensuite transmises au fournisseur d\'identité en arrière-plan pour obtenir son authentification. Cette méthode est la plus transparente pour l\'utilisateur car il n\'a aucune interaction directe avec le fournisseur d\'identité. Notez que l\'ensemble des fournisseurs d\'identité prennent en charge ce flux.'; $string['cfg_oidcresource_key'] = 'Ressource'; $string['cfg_oidcresource_desc'] = 'Ressource OpenID Connect pour laquelle envoyer la demande.'; $string['cfg_oidcscope_key'] = 'Porté'; diff --git a/lang/it/auth_oidc.php b/lang/it/auth_oidc.php index eed128572..5dc952dee 100644 --- a/lang/it/auth_oidc.php +++ b/lang/it/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Flusso di login'; $string['cfg_loginflow_authcode'] = 'Richiesta di autorizzazione'; $string['cfg_loginflow_authcode_desc'] = 'Utilizzando questo flusso, l\'utente fa clic sul nome dell\'Identity Provider (vedere "Nome provider" in precedenza) nella pagina login di Moodle e viene reindirizzato al provider per il login. Dopo il login, l\'utente viene nuovamente reindirizzato in Moodle dove il login a Moodle viene eseguito in maniera trasparente. Questo è il metodo di login più standardizzato e sicuro.'; -$string['cfg_loginflow_rocreds'] = 'Autenticazione nome utente/password'; -$string['cfg_loginflow_rocreds_desc'] = 'Utilizzando questo flusso, l\'utente inserisce il nome utente e la password nel modulo di login di Moodle seguendo la stessa procedura del login manuale. Le credenziali vengono quindi passate all\'Identity Provider in background per ottenere l\'autenticazione. Questo flusso è quello più trasparente per l\'utente in quanto non esiste interazione diretta con l\'Identity Provider. Osservare che non tutti gli Identity Provider supportano questo flusso.'; $string['cfg_oidcresource_key'] = 'Risorsa'; $string['cfg_oidcresource_desc'] = 'La risorsa OpenID Connect per la quale inviare la richiesta.'; $string['cfg_oidcscope_key'] = 'Scopo'; diff --git a/lang/ja/auth_oidc.php b/lang/ja/auth_oidc.php index 12adba749..038bd27d0 100644 --- a/lang/ja/auth_oidc.php +++ b/lang/ja/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'ログインフロー'; $string['cfg_loginflow_authcode'] = '認証リクエスト'; $string['cfg_loginflow_authcode_desc'] = 'このフローでは、ユーザはMoodleログインページでアイデンティティプロバイダの名前 (上記の「プロバイダ名」を参照) をクリックします。ユーザはプロバイダにリダイレクトされ、そこでログインします。ログインが成功したら、ユーザはMoodleにリダイレクトされ、透過的にMoodleログインが行われます。これは最も標準化され、最もセキュアなユーザのログイン方法です。'; -$string['cfg_loginflow_rocreds'] = 'ユーザ名/パスワード認証'; -$string['cfg_loginflow_rocreds_desc'] = 'このフローでは、手動によるログインと同様、ユーザはMoodleのログインフォームにユーザ名とパスワードを入力します。これらの認証情報はバックグラウンドでアイデンティティプロバイダに渡され、認証を取得します。ユーザはアイデンティティプロバイダと直接やり取りしないので、このフローはユーザに最も透過的です。すべてのアイデンティティプロバイダがこのフローをサポートしているわけではない点にご注意ください。'; $string['cfg_oidcresource_key'] = 'リソース'; $string['cfg_oidcresource_desc'] = 'リクエストを送る、OpenID Connectのリソース。'; $string['cfg_oidcscope_key'] = '範囲'; diff --git a/lang/nl/auth_oidc.php b/lang/nl/auth_oidc.php index c79ec3585..831a01bd2 100644 --- a/lang/nl/auth_oidc.php +++ b/lang/nl/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Aanmeldingsflow'; $string['cfg_loginflow_authcode'] = 'Autorisatieverzoek'; $string['cfg_loginflow_authcode_desc'] = 'In deze flow klikt de gebruiker op de Moodle-aanmeldingspagina op de naam van de identiteitsprovider (zie Naam provider hierboven), waarna de gebruiker naar de provider wordt omgeleid om zich aan te melden. Wanneer de gebruiker is aangemeld, wordt de gebruiker weer teruggeleid naar Moodle, waar de Moodle-aanmelding transparant wordt uitgevoerd. Dit is de meest gestandaardiseerde en veilige manier waarop de gebruiker zich kan aanmelden.'; -$string['cfg_loginflow_rocreds'] = 'Authenticatie met gebruikersnaam/wachtwoord'; -$string['cfg_loginflow_rocreds_desc'] = 'In deze flow voert de gebruiker zijn gebruikersnaam en wachtwoord in het aanmeldingsformulier van Moodle in, net als bij een handmatige aanmelding. De referenties van de gebruiker worden daarna op de achtergrond doorgegeven aan de identiteitsprovider om authenticatie te verkrijgen. Deze werkwijze is de meest transparante voor de gebruiker omdat er geen directe interactie is met de identiteitsprovider. Niet alle identiteitsproviders ondersteunen deze werkwijze.'; $string['cfg_oidcresource_key'] = 'Bron'; $string['cfg_oidcresource_desc'] = 'De OpenID Connect-bron waarvoor het verzoek moet worden verzonden.'; $string['cfg_oidcscope_key'] = 'Reikwijdte'; diff --git a/lang/pl/auth_oidc.php b/lang/pl/auth_oidc.php index 0280a4009..2bbd6e271 100644 --- a/lang/pl/auth_oidc.php +++ b/lang/pl/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Przepływ logowania'; $string['cfg_loginflow_authcode'] = 'Żądanie autoryzacji'; $string['cfg_loginflow_authcode_desc'] = 'W przypadku tego przepływu użytkownik klika nazwę dostawcy tożsamości (patrz „Nazwa dostawcy” powyżej) na stronie logowania do platformy Moodle i zostaje przekierowany do dostawcy, aby się zalogować. Po pomyślnym zalogowaniu użytkownik jest ponownie przekierowywany do strony platformy Moodle, na której odbywa się logowanie do platformy Moodle w sposób niewidoczny. Jest to najbardziej ustandaryzowany i bezpieczny sposób logowania się użytkownika.'; -$string['cfg_loginflow_rocreds'] = 'Uwierzytelnienie nazwy użytkownika/hasła'; -$string['cfg_loginflow_rocreds_desc'] = 'W przypadku tego przepływu użytkownik wprowadza nazwę użytkownika i hasło do formularza logowania się do platformy Moodle w taki sam sposób jak w przypadku logowania ręcznego. Dane logowania użytkownika są następnie przesyłane do dostawcy tożsamości w tle w celu uwierzytelnienia. Ten przepływ jest najbardziej niewidoczny dla użytkownika, ponieważ użytkownik nie wchodzi w bezpośrednią interakcję z dostawcą tożsamości. Nie wszyscy dostawcy tożsamości obsługują ten przepływ.'; $string['cfg_oidcresource_key'] = 'Zasób'; $string['cfg_oidcresource_desc'] = 'Zasób wtyczki OpenID Connect, do którego ma zostać wysłane żądanie.'; $string['cfg_oidcscope_key'] = 'Scope'; diff --git a/lang/pt_br/auth_oidc.php b/lang/pt_br/auth_oidc.php index 1d2218978..22831cdb5 100644 --- a/lang/pt_br/auth_oidc.php +++ b/lang/pt_br/auth_oidc.php @@ -65,8 +65,6 @@ $string['cfg_loginflow_key'] = 'Fluxo de login'; $string['cfg_loginflow_authcode'] = 'Solicitação de autorização'; $string['cfg_loginflow_authcode_desc'] = 'Ao usar esse fluxo, o usuário clicará no nome do provedor de identidade (consulte "Nome do provedor" acima) na página de login do Moodle e será redirecionado para o provedor para fazer login. Depois de efetuar com sucesso o login, o usuário será redirecionado de volta para o Moodle, onde o login ocorrerá de modo transparente. Essa é a maneira mais padronizada e segura de realizar o login do usuário.'; -$string['cfg_loginflow_rocreds'] = 'Autenticação de nome de usuário e senha'; -$string['cfg_loginflow_rocreds_desc'] = 'Ao usar esse fluxo, o usuário informará seu nome de usuário e sua senha no formulário de login do Moodle da mesma forma que faria em um login manual. As credenciais serão, então, transmitidas em segundo plano para o provedor de identidade no intuito de obter a autenticação. Esse fluxo é o mais simples para o usuário, pois ele não interage diretamente com o provedor de identidade. Tenha em mente que nem todos os provedores de identidade aceitam a utilização desse fluxo.'; $string['cfg_oidcresource_key'] = 'Recurso'; $string['cfg_oidcresource_desc'] = 'O recurso do OpenID Connect para o qual a solicitação deverá ser enviada.'; $string['cfg_oidcscope_key'] = 'Escopo'; diff --git a/version.php b/version.php index cb0064cef..1c86c82fe 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.01; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From 3b81eeb461ea7f299eab61bb232198a6f9502b92 Mon Sep 17 00:00:00 2001 From: Patryk Mroczko Date: Fri, 31 Jul 2026 12:19:39 +0200 Subject: [PATCH 07/36] Fix open redirect in authcode handleredirect by using is_valid_local_url() instead of strpos --- classes/loginflow/authcode.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..f08bd9c42 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -112,6 +112,13 @@ protected function is_valid_local_url(string $urlstring): bool { return false; } + // Path must start with the wwwroot path (handles subdirectory installs). + $wwwrootpath = rtrim($wwwroot['path'] ?? '', '/') . '/'; + $checkpath = $checkurl['path'] ?? '/'; + if (strpos($checkpath . '/', $wwwrootpath) !== 0) { + return false; + } + return true; } @@ -195,7 +202,7 @@ public function handleredirect() { $this->handleauthresponse($requestparams); } else { if (isloggedin() && !isguestuser() && empty($justauth) && empty($promptaconsent)) { - if (isset($SESSION->wantsurl) && (strpos($SESSION->wantsurl, $CFG->wwwroot) === 0)) { + if (isset($SESSION->wantsurl) && $this->is_valid_local_url($SESSION->wantsurl)) { $urltogo = $SESSION->wantsurl; unset($SESSION->wantsurl); } else { From 1ac1830c2a9f5e4eca2548a111cc9c403aeeb638 Mon Sep 17 00:00:00 2001 From: Valerio Carnevale Date: Fri, 19 Jun 2026 12:34:24 +0200 Subject: [PATCH 08/36] auth_oidc: added hook before_login_completed to do some checks before user login is completed --- classes/hook/before_login_completed.php | 39 +++++++++++++++++++++++++ classes/loginflow/authcode.php | 5 ++++ 2 files changed, 44 insertions(+) create mode 100644 classes/hook/before_login_completed.php diff --git a/classes/hook/before_login_completed.php b/classes/hook/before_login_completed.php new file mode 100644 index 000000000..521c5d067 --- /dev/null +++ b/classes/hook/before_login_completed.php @@ -0,0 +1,39 @@ +. + +namespace auth_oidc\hook; + +use auth_oidc\jwt; + +/** + * Allow plugins to callback as soon possible after user has completed login. + * + * @package auth_oidc + * @copyright 2026 Ariadne + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allow plugins to callback as soon possible after user has completed login.')] +#[\core\attribute\tags('user', 'login')] +class before_login_completed { + /** + * Constructor for the hook. + */ + public function __construct( + /** @var jwt The course instance */ + public readonly jwt $idtoken + ) { + } +} diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..e5e0143ff 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -38,6 +38,7 @@ use core\url; use pix_icon; use stdClass; +use core\di; defined('MOODLE_INTERNAL') || die(); @@ -738,6 +739,10 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok $this->updatetoken($tokenrec->id, $authparams, $tokenparams); $user = authenticate_user_login($username, '', true); + // Look for plugins that want to add extra checks before user login is completed. + $hook = new \auth_oidc\hook\before_login_completed($idtoken); + di::get(\core\hook\manager::class)->dispatch($hook); + if (!empty($user)) { complete_user_login($user); } else { From 211b6b2ad789a1a042739cb87ddb6058371ffde0 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Fri, 31 Jul 2026 16:35:37 +0100 Subject: [PATCH 09/36] auth_oidc: address review feedback for before_login_completed hook - Dispatch the hook in all three handlelogin() branches (it previously only fired for already-connected users, missing the username-changed and new/unconnected-user paths - the latter being the main case for gating first-time logins). - Dispatch only after authenticate_user_login() succeeds, immediately before complete_user_login(), instead of unconditionally. - Fix the hook's PHPDoc/attribute text and $idtoken property doc, which were copy-pasted from unrelated core hooks. - Add the missing @param tag for the constructor. - Document the login-rejection contract: callbacks reject a login by throwing an exception (e.g. \moodle_exception), since the hook manager does not catch callback exceptions. --- classes/hook/before_login_completed.php | 17 ++++++++++++++--- classes/loginflow/authcode.php | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/classes/hook/before_login_completed.php b/classes/hook/before_login_completed.php index 521c5d067..0a79b198b 100644 --- a/classes/hook/before_login_completed.php +++ b/classes/hook/before_login_completed.php @@ -19,20 +19,31 @@ use auth_oidc\jwt; /** - * Allow plugins to callback as soon possible after user has completed login. + * Allow plugins to perform additional checks before a user login is completed. + * + * This hook is dispatched by auth_oidc after authenticate_user_login() has + * succeeded, but before complete_user_login() is called. The hook manager + * does not catch exceptions raised by callbacks, so a callback can reject + * the login by throwing an exception (e.g. \moodle_exception) - doing so + * will propagate out of the hook dispatch and prevent complete_user_login() + * from being called. There is no other signal (e.g. a flag on this hook) to + * reject the login; a plain return from a callback allows the login to + * proceed. * * @package auth_oidc * @copyright 2026 Ariadne * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -#[\core\attribute\label('Allow plugins to callback as soon possible after user has completed login.')] +#[\core\attribute\label('Allow plugins to perform additional checks before a user login is completed.')] #[\core\attribute\tags('user', 'login')] class before_login_completed { /** * Constructor for the hook. + * + * @param jwt $idtoken The id_token of the user attempting to log in. */ public function __construct( - /** @var jwt The course instance */ + /** @var jwt The id_token of the user attempting to log in */ public readonly jwt $idtoken ) { } diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index e5e0143ff..51e9e00b2 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -739,11 +739,11 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok $this->updatetoken($tokenrec->id, $authparams, $tokenparams); $user = authenticate_user_login($username, '', true); - // Look for plugins that want to add extra checks before user login is completed. - $hook = new \auth_oidc\hook\before_login_completed($idtoken); - di::get(\core\hook\manager::class)->dispatch($hook); - if (!empty($user)) { + // Look for plugins that want to add extra checks before user login is completed. + $hook = new \auth_oidc\hook\before_login_completed($idtoken); + di::get(\core\hook\manager::class)->dispatch($hook); + complete_user_login($user); } else { // There was a problem in authenticate_user_login. @@ -814,6 +814,10 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok $user = authenticate_user_login($username, '', true); if (!empty($user)) { + // Look for plugins that want to add extra checks before user login is completed. + $hook = new \auth_oidc\hook\before_login_completed($idtoken); + di::get(\core\hook\manager::class)->dispatch($hook); + complete_user_login($user); } else { // There was a problem in authenticate_user_login. @@ -900,6 +904,11 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok $updatedtokenrec->userid = $user->id; $DB->update_record('auth_oidc_token', $updatedtokenrec); } + + // Look for plugins that want to add extra checks before user login is completed. + $hook = new \auth_oidc\hook\before_login_completed($idtoken); + di::get(\core\hook\manager::class)->dispatch($hook); + complete_user_login($user); } else { // There was a problem in authenticate_user_login. Clean up incomplete token record. From 8ca01c27d1116e4e32c52b84c9884942aca0b002 Mon Sep 17 00:00:00 2001 From: Christian Abila Date: Mon, 6 Jul 2026 12:12:03 +0200 Subject: [PATCH 10/36] add information on notification start --- lang/en/auth_oidc.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..e8904439b 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -175,7 +175,8 @@ '; $string['secretexpiryrecipients'] = 'Secret Expiry Notification Recipients'; $string['secretexpiryrecipients_help'] = 'A comma-separated list of email addresses to send secret expiry notifications to.
-If no email address is entered, the main site administrator will be notified.'; +If no email address is entered, the main site administrator will be notified.
+Nofications are sent daily from four weeks before expiration until the secret has been renewed.'; $string['cfg_opname_key'] = 'Provider Display Name'; $string['cfg_opname_desc'] = 'This is an end-user-facing label that identifies the type of credentials the user must use to login. This label is used throughout the user-facing portions of this plugin to identify your provider.'; $string['cfg_redirecturi_key'] = 'Redirect URI'; From fefccd7d4aabfb34bf65c4e0e4c3a70c2f29156b Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Fri, 31 Jul 2026 17:02:54 +0100 Subject: [PATCH 11/36] Fix wording issues in secret expiry notification help string - Fix typo "Nofications" -> "Notifications" - Qualify notification frequency as the default schedule, since the scheduled task can be rescheduled by admins - Use "expiry" consistently instead of "expiration" to match other strings in this file --- lang/en/auth_oidc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index e8904439b..9d76e826e 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -176,7 +176,7 @@ $string['secretexpiryrecipients'] = 'Secret Expiry Notification Recipients'; $string['secretexpiryrecipients_help'] = 'A comma-separated list of email addresses to send secret expiry notifications to.
If no email address is entered, the main site administrator will be notified.
-Nofications are sent daily from four weeks before expiration until the secret has been renewed.'; +By default, notifications are sent daily from four weeks before expiry until the secret has been renewed.'; $string['cfg_opname_key'] = 'Provider Display Name'; $string['cfg_opname_desc'] = 'This is an end-user-facing label that identifies the type of credentials the user must use to login. This label is used throughout the user-facing portions of this plugin to identify your provider.'; $string['cfg_redirecturi_key'] = 'Redirect URI'; From c35090c5b240327cb75ca49716c21b1328be1e0b Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 5 Aug 2026 09:44:16 +0100 Subject: [PATCH 12/36] Fix bug in XMLDB definition and index creation in upgrade script --- classes/utils.php | 57 +++++++++++++++++++++++++++++++++++++++++++++++ db/install.php | 11 +++------ db/install.xml | 2 +- db/upgrade.php | 48 +++++++-------------------------------- version.php | 2 +- 5 files changed, 70 insertions(+), 50 deletions(-) diff --git a/classes/utils.php b/classes/utils.php index 5dbaf86dd..86034f8e3 100644 --- a/classes/utils.php +++ b/classes/utils.php @@ -252,4 +252,61 @@ public static function get_openssl_internal_path() { return $CFG->dataroot . '/microsoft_certs'; } + + /** + * Add a unique constraint on (oidcuniqid, tokenresource) to the auth_oidc_token table, removing duplicate + * tokens first. + * + * 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. + * + * The combined length of oidcuniqid (255 chars) and tokenresource (127 chars) exceeds the byte + * limit the XMLDB API enforces on composed indexes (xmldb_index::INDEX_COMPOSED_MAX_BYTES), even + * though both MySQL and PostgreSQL can create the index without issue. So the index is created + * with raw SQL instead of $dbman->add_index(), with the index name manually prefixed with the + * site's table prefix to avoid name collisions with other prefixes (e.g. PHPUnit or Behat test + * tables) sharing the same database/schema. + */ + public static function add_token_unique_constraint(): void { + global $DB; + + $dbman = $DB->get_manager(); + $table = new \xmldb_table('auth_oidc_token'); + $index = new \xmldb_index('oidcuniqid-tokenresource', XMLDB_INDEX_UNIQUE, ['oidcuniqid', 'tokenresource']); + + if ($dbman->index_exists($table, $index)) { + // Unique constraint already present, nothing to do. + return; + } + + // Deliberately let any failure here propagate: swallowing it would let the calling + // upgrade step reach its savepoint even though the uniqueness guarantee was never + // established, silently leaving the database inconsistent with the code. + $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). + // Note: PostgreSQL does not accept the TEMPORARY keyword in DROP TABLE (only in CREATE + // TABLE), so plain DROP TABLE is used here; it works for temporary tables on MySQL too. + $sql = "DROP TABLE IF EXISTS {" . $temptable . "}"; + $DB->execute($sql); + + // Step 5: Add unique constraint on (oidcuniqid, tokenresource) to prevent duplicate tokens. + $indexname = $DB->get_prefix() . 'authoidctoken_uniq_ix'; + $sql = "CREATE UNIQUE INDEX {$indexname} ON {auth_oidc_token} (oidcuniqid, tokenresource)"; + $DB->execute($sql); + } } diff --git a/db/install.php b/db/install.php index b8612d116..31bcadf7d 100644 --- a/db/install.php +++ b/db/install.php @@ -27,18 +27,13 @@ * 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); + // The unique constraint on (oidcuniqid, tokenresource) can't be declared in install.xml: it + // exceeds the XMLDB API's byte limit for composed indexes, so it must be added with raw SQL. + \auth_oidc\utils::add_token_unique_constraint(); } diff --git a/db/install.xml b/db/install.xml index 038f48779..74bfade0c 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - diff --git a/db/upgrade.php b/db/upgrade.php index c4d776684..8c4094ec5 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -24,6 +24,8 @@ * @copyright (C) 2014 onwards Microsoft, Inc. (http://microsoft.com/) */ +use auth_oidc\utils; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/auth/oidc/lib.php'); @@ -582,48 +584,14 @@ function xmldb_auth_oidc_upgrade($oldversion) { } if ($oldversion < 2025100601.01) { - upgrade_auth_oidc_add_token_constraint(); + utils::add_token_unique_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); + if ($oldversion < 2026042000.02) { + utils::add_token_unique_constraint(); + upgrade_plugin_savepoint(true, 2026042000.02, 'auth', 'oidc'); } + + return true; } diff --git a/version.php b/version.php index cb0064cef..5eb7690aa 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.02; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From 0e3d0a9919caadb73918136c955837a9a51f0400 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 5 Aug 2026 11:44:24 +0100 Subject: [PATCH 13/36] Fix section error for auth_oidc configuration page --- lib.php | 2 +- settings.php | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib.php b/lib.php index 5d13a5ae0..40ccfd852 100644 --- a/lib.php +++ b/lib.php @@ -1066,7 +1066,7 @@ function auth_oidc_is_masked_secret($value) { */ function auth_oidc_get_settings_nav_html(string $currentpage): string { $pages = [ - 'auth_oidc_application' => get_string('settings_page_application', 'auth_oidc'), + 'authsettingoidc' => get_string('settings_page_application', 'auth_oidc'), ]; // Only include the binding username claim tab if IdP type is configured. diff --git a/settings.php b/settings.php index 2c1d9774a..26bca8943 100644 --- a/settings.php +++ b/settings.php @@ -39,7 +39,7 @@ // 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'])); + redirect(new \core\url('/admin/settings.php', ['section' => 'authsettingoidc'])); } // Add folder for OIDC settings. @@ -47,8 +47,13 @@ $ADMIN->add('authsettings', $oidcfolder); // Application configuration settings page. + // Registered as 'authsettingoidc' (Moodle's "authsetting" convention for auth + // plugins) rather than a custom id, because the core "Manage authentication" page + // (admin_setting_manageauths::output_html in lib/adminlib.php) hardcodes the settings + // link for each auth plugin to section "authsetting". Using any other id here + // leaves that link pointing at a non-existent section and triggers a "section error". $applicationsettings = new admin_settingpage( - 'auth_oidc_application', + 'authsettingoidc', get_string('settings_page_application', 'auth_oidc') ); @@ -56,7 +61,7 @@ $applicationsettings->add(new admin_setting_heading( 'auth_oidc_application_nav', '', - auth_oidc_get_settings_nav_html('auth_oidc_application') + auth_oidc_get_settings_nav_html('authsettingoidc') )); // Link to the guided Application Configuration Wizard. From c9e1066a529c21185801adfae005b2070ec18d72 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 5 Aug 2026 12:56:14 +0100 Subject: [PATCH 14/36] Add configurable login state expiry and friendly error page for expired/unknown OIDC login state --- .../auth_oidc_admin_setting_stateexpiry.php | 54 +++++++ classes/loginflow/authcode.php | 51 ++++++- classes/task/cleanup_oidc_state_and_token.php | 7 +- lang/en/auth_oidc.php | 12 ++ settings.php | 55 +++++++ .../cleanup_oidc_state_and_token_test.php | 139 ++++++++++++++++++ version.php | 2 +- 7 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 classes/adminsetting/auth_oidc_admin_setting_stateexpiry.php create mode 100644 tests/task/cleanup_oidc_state_and_token_test.php diff --git a/classes/adminsetting/auth_oidc_admin_setting_stateexpiry.php b/classes/adminsetting/auth_oidc_admin_setting_stateexpiry.php new file mode 100644 index 000000000..dd1c92be9 --- /dev/null +++ b/classes/adminsetting/auth_oidc_admin_setting_stateexpiry.php @@ -0,0 +1,54 @@ +. + +/** + * Admin setting class for the OIDC login state expiry setting. + * + * @package auth_oidc + * @author Lai Wei + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright (C) 2026 onwards Microsoft, Inc. (http://microsoft.com/) + */ + +namespace auth_oidc\adminsetting; + +/** + * Admin setting for the OIDC login state expiry, in minutes. + * + * Extends the standard text setting with validation that rejects values below one minute. A + * value of zero (or less) would cause the state cleanup task to delete login state records + * almost immediately, breaking any login that is currently in progress. + */ +class auth_oidc_admin_setting_stateexpiry extends \admin_setting_configtext { + /** + * Validate the submitted expiry value. + * + * @param string $data The submitted 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; + } + + if ((int) $data < 1) { + return get_string('error_stateexpiry_min', 'auth_oidc'); + } + + return true; + } +} diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..2da826c0c 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -31,6 +31,7 @@ use auth_oidc\event\user_rename_attempt; use auth_oidc\jwt; use auth_oidc\utils; +use core\context\system; use core\output\notification; use core_text; use core_user; @@ -280,6 +281,48 @@ public function initiateadminconsentrequest(array $stateparams = [], array $extr $client->adminconsentrequest($stateparams, $extraparams); } + /** + * Handle a login state that could not be matched to a stored state record. + * + * This normally happens when the user takes longer than the state record's lifetime to + * complete login at the identity provider (for example, while approving a multi-factor + * authentication prompt), so the scheduled cleanup task has already deleted the record by + * the time the identity provider redirects back. When the "stateredirect_enabled" setting is + * on, this shows a customizable, friendly message and automatically redirects the user back + * to the login page instead of surfacing the generic Moodle error page. + * + * @return never + * @throws moodle_exception If the friendly redirect setting is disabled. + */ + protected function handlemissingstaterecord(): never { + global $OUTPUT, $PAGE; + + if (empty(get_config('auth_oidc', 'stateredirect_enabled'))) { + throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); + } + + $PAGE->set_url('/auth/oidc/'); + $PAGE->set_context(system::instance()); + $PAGE->set_pagelayout('redirect'); + $PAGE->set_title(get_string('pageshouldredirect')); + + $message = get_config('auth_oidc', 'stateredirect_message'); + if (empty($message)) { + $message = get_string('errorauthunknownstate', 'auth_oidc'); + } + $message = format_text($message, FORMAT_HTML, ['context' => system::instance()]); + + $delay = (int) get_config('auth_oidc', 'stateredirect_delay'); + if ($delay < 0) { + $delay = 0; + } + + $url = new url('/login/index.php'); + + echo $OUTPUT->redirect_message($url->out(), $message, $delay, false, notification::NOTIFY_ERROR); + exit; + } + /** * Handles the response for certificate-based admin consent authorization. * @@ -297,13 +340,13 @@ protected function handlecertadminconsentresponse(array $authparams) { if (!isset($authparams['state'])) { utils::debug('No state received.', __METHOD__, $authparams); - throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); + $this->handlemissingstaterecord(); } // Validate and expire state. $staterec = $DB->get_record('auth_oidc_state', ['state' => $authparams['state']]); if (empty($staterec)) { - throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); + $this->handlemissingstaterecord(); } $orignonce = $staterec->nonce; @@ -360,13 +403,13 @@ protected function handleauthresponse(array $authparams) { if (!isset($authparams['state'])) { utils::debug('No state received.', __METHOD__, $authparams); - throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); + $this->handlemissingstaterecord(); } // Validate and expire state. $staterec = $DB->get_record('auth_oidc_state', ['state' => $authparams['state']]); if (empty($staterec)) { - throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); + $this->handlemissingstaterecord(); } $orignonce = $staterec->nonce; diff --git a/classes/task/cleanup_oidc_state_and_token.php b/classes/task/cleanup_oidc_state_and_token.php index eb5304c2f..c6e321238 100644 --- a/classes/task/cleanup_oidc_state_and_token.php +++ b/classes/task/cleanup_oidc_state_and_token.php @@ -45,7 +45,12 @@ public function execute() { global $DB; // Clean up oidc state. - $DB->delete_records_select('auth_oidc_state', 'timecreated < ?', [strtotime('-5 min')]); + $stateexpiry = (int) get_config('auth_oidc', 'stateexpiry'); + if ($stateexpiry <= 0) { + $stateexpiry = 5; + } + $cutoff = time() - ($stateexpiry * MINSECS); + $DB->delete_records_select('auth_oidc_state', 'timecreated < ?', [$cutoff]); // Clean up invalid oidc token. $DB->delete_records('auth_oidc_token', ['userid' => 0]); diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..9cee68599 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -43,6 +43,8 @@ $string['heading_basic_desc'] = ''; $string['heading_additional_options'] = 'Additional options'; $string['heading_additional_options_desc'] = ''; +$string['heading_stateredirect'] = 'Login state error page'; +$string['heading_stateredirect_desc'] = 'By default, if a user takes too long to complete login at the identity provider (for example, while approving a multi-factor authentication prompt) and the stored login state has since been cleaned up, Moodle shows its generic error page. The settings below let you extend how long login state is kept, and let you show a friendlier, customizable message instead of the generic error page, automatically redirecting the user back to the login page.'; $string['heading_user_restrictions'] = 'User restrictions'; $string['heading_user_restrictions_desc'] = ''; $string['heading_sign_out'] = 'Sign out integration'; @@ -115,6 +117,16 @@ $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_stateexpiry_key'] = 'Login state expiry (minutes)'; +$string['cfg_stateexpiry_desc'] = 'How long a login state record is kept before the scheduled cleanup task removes it. Increase this if users regularly take longer than this to complete login at the identity provider (for example, due to multi-factor authentication prompts).'; +$string['error_stateexpiry_min'] = 'The login state expiry must be at least 1 minute. A value of zero or less would cause logins that are currently in progress to fail.'; +$string['cfg_stateredirect_enabled_key'] = 'Show friendly error page'; +$string['cfg_stateredirect_enabled_desc'] = 'If enabled, a user who takes too long to complete login at the identity provider (so that the stored login state has since been cleaned up) is shown the customizable message below and automatically redirected back to the login page, instead of Moodle\'s generic error page.'; +$string['cfg_stateredirect_message_key'] = 'Message'; +$string['cfg_stateredirect_message_desc'] = 'The message to display to the user on the friendly error page.'; +$string['cfg_stateredirect_message_default'] = 'There was a problem logging you in. This is most likely because the login took too long to complete (for example, while approving a multi-factor authentication prompt). You will be redirected to the login page automatically so you can try again.'; +$string['cfg_stateredirect_delay_key'] = 'Redirect delay (seconds)'; +$string['cfg_stateredirect_delay_desc'] = 'The number of seconds to show the message above before automatically redirecting the user back to the login page.'; $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'; diff --git a/settings.php b/settings.php index 2c1d9774a..00a0ebfec 100644 --- a/settings.php +++ b/settings.php @@ -30,6 +30,7 @@ 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\adminsetting\auth_oidc_admin_setting_stateexpiry; use auth_oidc\utils; use core\url; @@ -577,6 +578,60 @@ ) ); + // Login state error page heading. + $settings->add( + new admin_setting_heading( + 'auth_oidc/stateredirect_heading', + get_string('heading_stateredirect', 'auth_oidc'), + get_string('heading_stateredirect_desc', 'auth_oidc') + ) + ); + + // Login state expiry. + $settings->add( + new auth_oidc_admin_setting_stateexpiry( + 'auth_oidc/stateexpiry', + get_string('cfg_stateexpiry_key', 'auth_oidc'), + get_string('cfg_stateexpiry_desc', 'auth_oidc'), + 5, + PARAM_INT + ) + ); + + // Enable friendly login state error page. + $settings->add( + new admin_setting_configcheckbox( + 'auth_oidc/stateredirect_enabled', + get_string('cfg_stateredirect_enabled_key', 'auth_oidc'), + get_string('cfg_stateredirect_enabled_desc', 'auth_oidc'), + '0' + ) + ); + + // Message to display on the friendly login state error page. + $settings->add( + new admin_setting_confightmleditor( + 'auth_oidc/stateredirect_message', + get_string('cfg_stateredirect_message_key', 'auth_oidc'), + get_string('cfg_stateredirect_message_desc', 'auth_oidc'), + get_string('cfg_stateredirect_message_default', 'auth_oidc') + ) + ); + + // Redirect delay for the friendly login state error page. + $settings->add( + new admin_setting_configtext( + 'auth_oidc/stateredirect_delay', + get_string('cfg_stateredirect_delay_key', 'auth_oidc'), + get_string('cfg_stateredirect_delay_desc', 'auth_oidc'), + 5, + PARAM_INT + ) + ); + + $settings->hide_if('auth_oidc/stateredirect_message', 'auth_oidc/stateredirect_enabled', 'notchecked'); + $settings->hide_if('auth_oidc/stateredirect_delay', 'auth_oidc/stateredirect_enabled', 'notchecked'); + // User restrictions heading. $settings->add( new admin_setting_heading( diff --git a/tests/task/cleanup_oidc_state_and_token_test.php b/tests/task/cleanup_oidc_state_and_token_test.php new file mode 100644 index 000000000..83d936366 --- /dev/null +++ b/tests/task/cleanup_oidc_state_and_token_test.php @@ -0,0 +1,139 @@ +. + +namespace auth_oidc\task; + +use advanced_testcase; +use dml_exception; + +/** + * Unit tests for the class cleanup_oidc_state_and_token + * + * @package auth_oidc + * @copyright 2026 Enovation Solutions + * @author Lai Wei + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @group auth_oidc + * @group office365 + * @coversDefaultClass \auth_oidc\task\cleanup_oidc_state_and_token + */ +final class cleanup_oidc_state_and_token_test extends advanced_testcase { + /** + * Insert an auth_oidc_state record with the given state string and timecreated. + * + * @param string $state Unique state string. + * @param int $timecreated Time the state record was created. + * @return int The id of the inserted record. + * @throws dml_exception + */ + private function create_state_record(string $state, int $timecreated): int { + global $DB; + + return $DB->insert_record('auth_oidc_state', [ + 'sesskey' => 'sesskey123', + 'state' => $state, + 'nonce' => 'nonce123', + 'timecreated' => $timecreated, + ]); + } + + /** + * When "stateexpiry" is not configured, state records older than the default 5 minutes are deleted. + * + * @return void + * @throws dml_exception + * @covers ::execute + */ + public function test_default_expiry_is_five_minutes(): void { + global $DB; + $this->resetAfterTest(); + + $now = time(); + $cutofftime = strtotime('-5 min', $now); + + $old = $cutofftime - MINSECS; + $new = $cutofftime + MINSECS; + + $oldid = $this->create_state_record('state_old', $old); + $newid = $this->create_state_record('state_new', $new); + + (new cleanup_oidc_state_and_token())->execute(); + + $this->assertFalse($DB->record_exists('auth_oidc_state', ['id' => $oldid])); + $this->assertTrue($DB->record_exists('auth_oidc_state', ['id' => $newid])); + } + + /** + * A configured "stateexpiry" value is honored, keeping records that the default 5 minute + * window would have deleted. + * + * @return void + * @throws dml_exception + * @covers ::execute + */ + public function test_configured_expiry_is_honored(): void { + global $DB; + $this->resetAfterTest(); + + set_config('stateexpiry', 15, 'auth_oidc'); + + $now = time(); + $cutofftime = strtotime('-15 min', $now); + + // 10 minutes old: would be deleted by the default 5 minute window, but must be kept + // with a configured 15 minute expiry. + $keptbyconfig = $now - (MINSECS * 10); + $old = $cutofftime - MINSECS; + $new = $cutofftime + MINSECS; + + $keptbyconfigid = $this->create_state_record('state_kept', $keptbyconfig); + $oldid = $this->create_state_record('state_old', $old); + $newid = $this->create_state_record('state_new', $new); + + (new cleanup_oidc_state_and_token())->execute(); + + $this->assertTrue($DB->record_exists('auth_oidc_state', ['id' => $keptbyconfigid])); + $this->assertFalse($DB->record_exists('auth_oidc_state', ['id' => $oldid])); + $this->assertTrue($DB->record_exists('auth_oidc_state', ['id' => $newid])); + } + + /** + * A "stateexpiry" of zero or less falls back to the default 5 minute window, rather than + * deleting state records almost immediately. + * + * @return void + * @throws dml_exception + * @covers ::execute + */ + public function test_non_positive_expiry_falls_back_to_default(): void { + global $DB; + $this->resetAfterTest(); + + set_config('stateexpiry', 0, 'auth_oidc'); + + $now = time(); + + // Created just now: would be deleted almost immediately by a literal zero-minute expiry, + // but must survive because zero falls back to the 5 minute default. + $recent = $now - MINSECS; + + $recentid = $this->create_state_record('state_recent', $recent); + + (new cleanup_oidc_state_and_token())->execute(); + + $this->assertTrue($DB->record_exists('auth_oidc_state', ['id' => $recentid])); + } +} diff --git a/version.php b/version.php index cb0064cef..02739af34 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.03; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From 6aa9c847f3789764ca3313cdb36a3e0c41a25382 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 5 Aug 2026 21:57:27 +0100 Subject: [PATCH 15/36] Update precision of sid column in auth_oidc_sid table --- db/install.xml | 4 ++-- db/upgrade.php | 14 ++++++++++++++ version.php | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/db/install.xml b/db/install.xml index 038f48779..e7c509543 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -65,7 +65,7 @@ - + diff --git a/db/upgrade.php b/db/upgrade.php index c4d776684..b806623e4 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -586,6 +586,20 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025100601.01, 'auth', 'oidc'); } + if ($oldversion < 2026042000.04) { + // Widen the sid field on auth_oidc_sid: it stores the session_state parameter, which can + // exceed 36 characters (e.g. two GUIDs joined by a dot), not a fixed-length identifier. + $table = new xmldb_table('auth_oidc_sid'); + $field = new xmldb_field('sid', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null, 'userid'); + + if ($dbman->field_exists($table, $field)) { + $dbman->change_field_precision($table, $field); + } + + // Oidc savepoint reached. + upgrade_plugin_savepoint(true, 2026042000.04, 'auth', 'oidc'); + } + return true; } diff --git a/version.php b/version.php index cb0064cef..d07b68fd4 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.04; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From ebaef5a62e2a74f4131c322d4f1bb063ad20d41d Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Thu, 6 Aug 2026 11:40:19 +0100 Subject: [PATCH 16/36] Fix OIDC account-linking failing under SameSite=Lax --- classes/loginflow/authcode.php | 35 ++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..c6ce5f162 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -264,6 +264,16 @@ public function initiateauthrequest( array $extraparams = [], bool $selectaccount = false ) { + global $USER; + + if (isloggedin() && !isguestuser()) { + // Record who initiated this request against the state record. The OIDC callback can arrive as a + // cross-site POST (response_mode=form_post), which a SameSite=Lax session cookie is not sent on, so the + // session may not survive the round trip. Storing the user id here lets handleauthresponse() identify + // the initiating user from the (unguessable, single-use) state record instead of the live session. + $stateparams['initiatinguserid'] = $USER->id; + } + $client = $this->get_oidcclient(); $client->authrequest($promptlogin, $stateparams, $extraparams, $selectaccount); } @@ -414,8 +424,29 @@ protected function handleauthresponse(array $authparams) { // Check if OIDC user is already migrated. $tokenrec = $DB->get_record('auth_oidc_token', ['oidcuniqid' => $oidcuniqid]); - if (isloggedin() && !isguestuser() && (empty($tokenrec) || (isset($USER->auth) && $USER->auth !== 'oidc'))) { - // If user is already logged in and trying to link Microsoft 365 account or use it for OIDC. + + // Determine who initiated this request. Prefer the user id stored against the state record over the live + // session: the state record is only returned by a legitimate, single-use round trip through the OP, so it + // can be trusted even when the session cookie did not survive the callback (e.g. SameSite=Lax blocking the + // cross-site POST used by response_mode=form_post). Fall back to the live session for state records created + // before this was captured. + $linkinguser = null; + if (!empty($additionaldata['initiatinguserid'])) { + $linkinguser = core_user::get_user((int)$additionaldata['initiatinguserid']); + if (!$linkinguser || !empty($linkinguser->deleted)) { + $linkinguser = null; + } + } else if (isloggedin() && !isguestuser()) { + $linkinguser = $USER; + } + + if ($linkinguser && (empty($tokenrec) || (isset($linkinguser->auth) && $linkinguser->auth !== 'oidc'))) { + // If the initiating user is trying to link a Microsoft 365 account or use it for OIDC, make sure the + // current session actually belongs to them, restoring it if the cookie was dropped on the callback. + if (!isloggedin() || isguestuser() || (int)$USER->id !== (int)$linkinguser->id) { + \core\session\manager::login_user($linkinguser); + } + // Check if that Microsoft 365 account already exists in moodle. $oidcusername = $this->get_oidc_username_from_token_claim($idtoken); From 9ea17bc1fa1bf78e2bb11b6d91664ff06278989b Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Thu, 6 Aug 2026 16:11:21 +0100 Subject: [PATCH 17/36] fix OIDC login for manually matched users with mismatching Entra username Manually matched users are keyed by Entra UPN, which often differs from the Moodle username. handlelogin() found the matched user but then discarded it, continuing with the UPN as the username and failing with "User not found in Moodle." Now it binds to the matched user's actual username instead. Also normalizes entraidupn casing on write and read to avoid case-mismatch failures. --- classes/loginflow/authcode.php | 10 +- tests/loginflow/authcode_test.php | 146 ++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/loginflow/authcode_test.php diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..e79da2f63 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -574,7 +574,11 @@ protected function check_for_matched($entraidupn) { global $DB; if (auth_oidc_is_local_365_installed()) { - $match = $DB->get_record('local_o365_connections', ['entraidupn' => $entraidupn]); + $entraidupn = trim($entraidupn); + $sql = 'SELECT * + FROM {local_o365_connections} + WHERE ' . $DB->sql_equal('entraidupn', ':entraidupn', false); + $match = $DB->get_record_sql($sql, ['entraidupn' => $entraidupn]); if (!empty($match) && \local_o365\utils::is_o365_connected($match->muserid) !== true) { return $DB->get_record('user', ['id' => $match->muserid]); } @@ -850,6 +854,10 @@ protected function handlelogin(string $oidcuniqid, array $authparams, array $tok $matchedwith->entraidupn = $username; throw new moodle_exception('errorusermatched', 'auth_oidc', null, $matchedwith); } + // The matched Moodle user is already set to auth 'oidc': bind the login to that user's own + // username rather than the Microsoft-derived one, which may not match it (e.g. a manual match + // keyed on the full UPN while the Moodle username is only the UPN prefix). + $username = $matchedwith->username; } $username = trim(core_text::strtolower($username)); $tokenrec = $this->createtoken($oidcuniqid, $username, $authparams, $tokenparams, $idtoken, 0, $originalupn); diff --git a/tests/loginflow/authcode_test.php b/tests/loginflow/authcode_test.php new file mode 100644 index 000000000..d0caae076 --- /dev/null +++ b/tests/loginflow/authcode_test.php @@ -0,0 +1,146 @@ +. + +namespace auth_oidc\loginflow; + +use advanced_testcase; +use auth_oidc\jwt; +use core\plugininfo\auth as auth_plugininfo; +use phpunit_util; + +/** + * Unit tests for the class \auth_oidc\loginflow\authcode. + * + * @package auth_oidc + * @copyright 2026 Enovation Solutions + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @group auth_oidc + * @group office365 + * @coversDefaultClass \auth_oidc\loginflow\authcode + */ +final class authcode_test extends advanced_testcase { + /** + * Set up test environment. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->resetAfterTest(true); + + auth_plugininfo::enable_plugin('oidc', 1); + set_config('bindingusernameclaim', 'upn', 'auth_oidc'); + } + + /** + * A manually matched user (auth already 'oidc', local_o365_connections keyed on the full Entra UPN, + * Moodle username only the UPN prefix) must complete on the very first OIDC login: the token must be + * bound to the matched Moodle user, not left dangling because the Moodle username differs from the UPN. + * + * Regression test for https://github.com/microsoft/o365-moodle/issues/2875. + * + * @return void + * @covers ::handlelogin + * @covers ::check_for_matched + */ + public function test_handlelogin_completes_manually_matched_user_with_differing_username(): void { + if (!auth_oidc_is_local_365_installed()) { + $this->markTestSkipped('This test requires local_o365 to be installed (local_o365_connections table).'); + } + + $this->assert_login_completes_for_matched_user('asmith@sc.school.edu.au', 'asmith@sc.school.edu.au'); + } + + /** + * A local_o365_connections row stored with mixed-case entraidupn (e.g. saved before UPNs were + * normalized to lower case, or entered that way by an admin) must still match a differently-cased + * UPN claim from the ID token, including on case-sensitive database collations such as PostgreSQL. + * + * @return void + * @covers ::check_for_matched + */ + public function test_handlelogin_completes_manually_matched_user_with_legacy_mixed_case_upn(): void { + if (!auth_oidc_is_local_365_installed()) { + $this->markTestSkipped('This test requires local_o365 to be installed (local_o365_connections table).'); + } + + $this->assert_login_completes_for_matched_user('ASmith@SC.School.edu.AU', 'asmith@sc.school.edu.au'); + } + + /** + * Creates a Moodle user manually matched to the given (as-stored) Entra UPN, drives handlelogin() with + * an ID token carrying the given (as-received) UPN claim, and asserts the login completed and bound + * the auth_oidc_token to the matched user. + * + * @param string $storedentraidupn The UPN as stored in local_o365_connections.entraidupn. + * @param string $tokenupn The UPN as received in the ID token's upn/preferred_username claims. + * @return void + */ + private function assert_login_completes_for_matched_user(string $storedentraidupn, string $tokenupn): void { + global $DB; + + $user = $this->getDataGenerator()->create_user(['username' => 'asmith', 'auth' => 'oidc']); + + // Simulate an admin manually matching the Moodle user to their Entra UPN via + // Manage User Connections, then flipping the account to auth 'oidc'. + $DB->insert_record('local_o365_connections', (object) [ + 'muserid' => $user->id, + 'entraidupn' => $storedentraidupn, + 'uselogin' => 0, + ]); + + $idtoken = new jwt(); + $idtoken->set_claims([ + 'sub' => 'sub-' . $user->id, + 'upn' => $tokenupn, + 'preferred_username' => $tokenupn, + ]); + + $oidcuniqid = 'oidcuniqid-' . $user->id; + $authparams = ['code' => 'authcode-' . $user->id]; + $tokenparams = [ + 'access_token' => 'access-token', + 'id_token' => 'id-token', + 'expires_in' => 3600, + 'resource' => 'resource', + 'scope' => 'scope', + ]; + + // The user_login() method validates the auth code against the current request, so it has to be + // available via optional_param() the same way it would be on a real OIDC callback request. + $_GET['code'] = $authparams['code']; + + try { + $loginflow = new authcode(); + phpunit_util::call_internal_method( + $loginflow, + 'handlelogin', + [$oidcuniqid, $authparams, $tokenparams, $idtoken], + authcode::class + ); + } finally { + unset($_GET['code']); + } + + $tokenrec = $DB->get_record('auth_oidc_token', ['oidcuniqid' => $oidcuniqid]); + $this->assertNotEmpty($tokenrec); + $this->assertEquals($user->id, $tokenrec->userid); + $this->assertEquals('asmith', $tokenrec->username); + + global $USER; + $this->assertEquals($user->id, $USER->id); + } +} From 4ccd05d447b6810ed215bd621ae712cae2fb928b Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Fri, 7 Aug 2026 18:12:28 +0100 Subject: [PATCH 18/36] Update icon settings in auth_oidc --- classes/adminsetting/iconselect.css | 1 + classes/loginflow/authcode.php | 10 ++- classes/utils.php | 96 ++++++++++++++++++++++++++++ db/upgrade.php | 5 ++ lang/en/auth_oidc.php | 23 ++----- lib.php | 31 ++++++++- pix/keycloak.png | Bin 0 -> 10506 bytes pix/microsoft.svg | 1 + pix/microsoft_365.svg | 34 ++++++++++ pix/microsoft_365_copilot.svg | 51 +++++++++++++++ pix/o365.png | Bin 497 -> 0 bytes pix/office_365.svg | 1 + pix/openid.svg | 31 +++++++++ settings.php | 89 ++++++++------------------ version.php | 2 +- 15 files changed, 290 insertions(+), 85 deletions(-) create mode 100644 pix/keycloak.png create mode 100644 pix/microsoft.svg create mode 100644 pix/microsoft_365.svg create mode 100644 pix/microsoft_365_copilot.svg delete mode 100644 pix/o365.png create mode 100644 pix/office_365.svg create mode 100644 pix/openid.svg diff --git a/classes/adminsetting/iconselect.css b/classes/adminsetting/iconselect.css index 6fe4121d4..d55e20f75 100644 --- a/classes/adminsetting/iconselect.css +++ b/classes/adminsetting/iconselect.css @@ -7,6 +7,7 @@ label.iconselect img { width: 25px; height: 25px; padding: 10px; + margin-right: 0; } input.iconselect { display: none; diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..5c8fac38f 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -59,25 +59,29 @@ public function loginpage_idp_list($wantsurl) { return []; } $showicon = isset($this->config->set_pix) ? $this->config->set_pix : true; + $name = strip_tags(format_text($this->config->opname)); $idpentry = [ 'url' => new url('/auth/oidc/', ['source' => 'loginpage']), - 'name' => strip_tags(format_text($this->config->opname)), + 'name' => $name, ]; if ($showicon) { if (!empty($this->config->customicon)) { $iconvalue = 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 = (!empty($this->config->icon)) ? $this->config->icon : 'auth_oidc:microsoft_365'; $icon = explode(':', $icon); if (isset($icon[1])) { [$iconcomponent, $iconname] = $icon; } else { $iconcomponent = 'auth_oidc'; - $iconname = 'o365'; + $iconname = 'microsoft_365'; } $iconvalue = new pix_icon($iconname, get_string('pluginname', 'auth_oidc'), $iconcomponent); } $idpentry['icon'] = $iconvalue; + // Two non-breaking spaces so the button text keeps a visible gap after the icon. + // Regular spaces would be collapsed to one by normal HTML whitespace handling. + $idpentry['name'] = "\u{00A0}\u{00A0}" . $name; } return [$idpentry]; } diff --git a/classes/utils.php b/classes/utils.php index 5dbaf86dd..2cf09c415 100644 --- a/classes/utils.php +++ b/classes/utils.php @@ -28,6 +28,7 @@ use Exception; use moodle_exception; use auth_oidc\event\action_failed; +use core\context\system; use core\url; /** @@ -252,4 +253,99 @@ public static function get_openssl_internal_path() { return $CFG->dataroot . '/microsoft_certs'; } + + /** + * Migrate a site's selected stock icon to the custom icon setting if it used one of the + * icon choices that have been removed from the icon selector. + * + * The 'auth_oidc/icon' setting stores a "component:pix" identifier. The set of stock + * choices has been reduced to a handful of icons relevant to this plugin; any site that had + * selected one of the removed choices (all generic core Moodle icons) needs that icon copied + * into the custom icon file area so the login page keeps showing the same image. + * + * Safe to call more than once: once a site has been migrated (or its 'icon' setting was + * never one of the removed choices), every subsequent call is a no-op, since the checks + * above always return early once either 'auth_oidc/icon' is empty/unset or + * 'auth_oidc/customicon' is populated. The file and config writes are wrapped in a + * delegated transaction so a failure partway through can't leave those two settings out of + * sync with each other, which is what the early-return checks rely on. + */ + public static function migrate_removed_icon_choices(): void { + global $CFG, $DB; + + $currenticon = get_config('auth_oidc', 'icon'); + if (empty($currenticon)) { + return; + } + + if (!empty(get_config('auth_oidc', 'customicon'))) { + // A custom icon is already in use and takes priority, so the stock icon setting is + // not currently affecting what is displayed. Nothing to migrate. + return; + } + + // The old default icon has simply been replaced with a new image under a new pix name. + if ($currenticon === 'auth_oidc:o365') { + set_config('icon', 'auth_oidc:office_365', 'auth_oidc'); + return; + } + + $keepicons = [ + 'auth_oidc:microsoft_365', + 'auth_oidc:microsoft', + 'auth_oidc:microsoft_365_copilot', + 'auth_oidc:office_365', + 'auth_oidc:openid', + 'auth_oidc:keycloak', + ]; + if (in_array($currenticon, $keepicons, true)) { + return; + } + + $parts = explode(':', $currenticon, 2); + if (count($parts) !== 2) { + return; + } + [, $pix] = $parts; + + $sourcefile = null; + $extension = null; + foreach (['svg', 'png', 'gif', 'jpg', 'jpeg'] as $candidateextension) { + $candidatefile = "{$CFG->dirroot}/pix/{$pix}.{$candidateextension}"; + if (file_exists($candidatefile)) { + $sourcefile = $candidatefile; + $extension = $candidateextension; + break; + } + } + if ($sourcefile === null) { + // Can't locate the source image for the removed choice, so there is nothing to copy. + return; + } + + $systemcontext = system::instance(); + $fs = get_file_storage(); + $filename = 'migrated_' . clean_param(str_replace('/', '_', $pix), PARAM_FILE) . '.' . $extension; + $filerecord = [ + 'contextid' => $systemcontext->id, + 'component' => 'auth_oidc', + 'filearea' => 'customicon', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + ]; + + // Wrapped in a transaction so a failure partway through (e.g. the file write succeeding + // but a config write failing) can't leave 'icon' and 'customicon' out of sync, which + // would break the early-return guards above on any later call. + $transaction = $DB->start_delegated_transaction(); + $fs->delete_area_files($systemcontext->id, 'auth_oidc', 'customicon', 0); + $fs->create_file_from_pathname($filerecord, $sourcefile); + set_config('customicon', '/' . $filename, 'auth_oidc'); + unset_config('icon', 'auth_oidc'); + $transaction->allow_commit(); + + require_once($CFG->dirroot . '/auth/oidc/lib.php'); + auth_oidc_initialize_customicon('/' . $filename); + } } diff --git a/db/upgrade.php b/db/upgrade.php index c4d776684..e75396392 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -586,6 +586,11 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025100601.01, 'auth', 'oidc'); } + if ($oldversion < 2026042000.05) { + \auth_oidc\utils::migrate_removed_icon_choices(); + upgrade_plugin_savepoint(true, 2026042000.05, 'auth', 'oidc'); + } + return true; } diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..5b6ff5b97 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -119,23 +119,14 @@ $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'; -$string['cfg_iconalt_locked'] = 'Locked icon'; -$string['cfg_iconalt_lock'] = 'Lock icon'; -$string['cfg_iconalt_go'] = 'Green circle'; -$string['cfg_iconalt_stop'] = 'Red circle'; -$string['cfg_iconalt_user'] = 'User icon'; -$string['cfg_iconalt_user2'] = 'User icon alternate'; -$string['cfg_iconalt_key'] = 'Key icon'; -$string['cfg_iconalt_group'] = 'Group icon'; -$string['cfg_iconalt_group2'] = 'Group icon alternate'; -$string['cfg_iconalt_mnet'] = 'MNET icon'; -$string['cfg_iconalt_userlock'] = 'User with lock icon'; -$string['cfg_iconalt_plus'] = 'Plus icon'; -$string['cfg_iconalt_check'] = 'Checkmark icon'; -$string['cfg_iconalt_rightarrow'] = 'Right-facing arrow icon'; +$string['cfg_iconalt_o365'] = 'Office 365 icon'; +$string['cfg_iconalt_microsoft365'] = 'Microsoft 365 icon'; +$string['cfg_iconalt_microsoft'] = 'Microsoft icon'; +$string['cfg_iconalt_microsoft365copilot'] = 'Microsoft 365 Copilot icon'; +$string['cfg_iconalt_openid'] = 'OpenID icon'; +$string['cfg_iconalt_keycloak'] = 'Keycloak icon'; $string['cfg_customicon_key'] = 'Custom Icon'; -$string['cfg_customicon_desc'] = 'If you\'d like to use your own icon, upload it here. This overrides any icon chosen above.

Notes on using custom icons:
  • This image will not be resized on the login page, so we recommend uploading an image no bigger than 35x35 pixels.
  • If you have uploaded a custom icon and want to go back to one of the stock icons, click the custom icon in the box above, then click "Delete", then click "OK", then click "Save Changes" at the bottom of this form. The selected stock icon will now appear on the Moodle login page.
'; +$string['cfg_customicon_desc'] = 'If you\'d like to use your own icon, upload it here. This overrides any icon chosen above.

Notes on using custom icons:
  • The uploaded file is not resized. It will be displayed at a fixed size of 24x24 pixels on the login page, with your browser scaling it to fit, so we recommend uploading a square image to avoid distortion.
  • If you have uploaded a custom icon and want to go back to one of the stock icons, click the custom icon in the box above, then click "Delete", then click "OK", then click "Save Changes" at the bottom of this form. The selected stock icon will now appear on the Moodle login page.
'; $string['cfg_debugmode_key'] = 'Record debug messages'; $string['cfg_debugmode_desc'] = 'If enabled, information will be logged to the Moodle log that can help in identifying problems.'; $string['cfg_loginflow_key'] = 'Login Flow'; diff --git a/lib.php b/lib.php index 5d13a5ae0..e7eab9f73 100644 --- a/lib.php +++ b/lib.php @@ -84,6 +84,17 @@ */ const AUTH_OIDC_AUTH_CERT_SOURCE_FILE = 2; +/** + * File extensions accepted for the 'auth_oidc/customicon' upload setting. + * + * SVG is deliberately excluded: unlike the plugin's own bundled stock icons, this file is + * admin-uploaded and served as-is from dataroot, so allowing SVG here would let an admin + * upload active content (script/event handlers). Shared by the setting's file picker + * (settings.php) and the extension allow-list checked before copying the file into + * pix_plugins (auth_oidc_initialize_customicon()) so the two can't drift apart. + */ +const AUTH_OIDC_CUSTOMICON_ALLOWED_EXTENSIONS = ['png', 'jpg', 'gif']; + /** * Callback invoked when application credentials or endpoint settings are updated. * @@ -227,7 +238,25 @@ function auth_oidc_initialize_customicon($filefullname) { } if (file_exists($CFG->dataroot . '/pix_plugins/auth/oidc/0')) { - $file->copy_content_to($CFG->dataroot . '/pix_plugins/auth/oidc/0/customicon.jpg'); + // Remove any previously stored custom icon so a stale file with a different + // extension can't take priority when the theme resolves the icon image. + $oldiconfiles = glob($CFG->dataroot . '/pix_plugins/auth/oidc/0/customicon.*'); + foreach ($oldiconfiles ?: [] as $oldiconfile) { + // A failed unlink (e.g. permissions, or the file already being gone) isn't fatal + // here: copy_content_to() below will still overwrite/create the current extension's + // file, so at worst a stale file of a different extension is left behind. + @unlink($oldiconfile); + } + + $extension = strtolower(pathinfo($file->get_filename(), PATHINFO_EXTENSION)); + if (!in_array($extension, AUTH_OIDC_CUSTOMICON_ALLOWED_EXTENSIONS, true)) { + // Unexpected/empty extension: don't create a weird or unvalidated file under + // pix_plugins. The stale files for previously-valid extensions were already + // removed above, so this leaves no custom icon in place. + return false; + } + + $file->copy_content_to($CFG->dataroot . "/pix_plugins/auth/oidc/0/customicon.{$extension}"); theme_reset_all_caches(); } } diff --git a/pix/keycloak.png b/pix/keycloak.png new file mode 100644 index 0000000000000000000000000000000000000000..48e18430cb06664c10a32008e13dd7b02ab3d879 GIT binary patch literal 10506 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 \ No newline at end of file diff --git a/pix/microsoft_365.svg b/pix/microsoft_365.svg new file mode 100644 index 000000000..53fc9a444 --- /dev/null +++ b/pix/microsoft_365.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pix/microsoft_365_copilot.svg b/pix/microsoft_365_copilot.svg new file mode 100644 index 000000000..cf32dbde5 --- /dev/null +++ b/pix/microsoft_365_copilot.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pix/o365.png b/pix/o365.png deleted file mode 100644 index 0fa022c25ea9ba897202f1fb751f64b8b9529227..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 497 zcmVB?)x<+801dA@%ak@$jjB)&yV2q%8!~BFqC` zz&qW#8SMlJf6p&Y&LfUxAuJ?-NEP!l@Cdk7dM%~GDHX<(6%!+`%DxJ-70^3iTVcr& zhTJ3CMcJqqy)yAK&`}N#i`H`3cdX`zD<<{0zExDHH=|p1-8JOYA8CLa;071~g(nr( zR2cj#xcsD5Sap&BA-AP*Q33>IU-;9!F6@7Iq$ z=Si2s9FPS1eeEI;s$zR~Ga9W4u2eQABM3=>86fIwKLC3`IuvHN=RofRgUbhuOOx}; ngjEs(S7O=eJ>d*h>!1As!hwLQcNwtj00000NkvXXu0mjfzc$O& diff --git a/pix/office_365.svg b/pix/office_365.svg new file mode 100644 index 000000000..66e8725af --- /dev/null +++ b/pix/office_365.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pix/openid.svg b/pix/openid.svg new file mode 100644 index 000000000..63a90e05c --- /dev/null +++ b/pix/openid.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/settings.php b/settings.php index 2c1d9774a..c2ce94ca8 100644 --- a/settings.php +++ b/settings.php @@ -679,79 +679,34 @@ // Icon. $icons = [ [ - 'pix' => 'o365', - 'alt' => new lang_string('cfg_iconalt_o365', 'auth_oidc'), + 'pix' => 'microsoft_365', + 'alt' => new lang_string('cfg_iconalt_microsoft365', 'auth_oidc'), 'component' => 'auth_oidc', ], [ - 'pix' => 't/locked', - 'alt' => new lang_string('cfg_iconalt_locked', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/lock', - 'alt' => new lang_string('cfg_iconalt_lock', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/go', - 'alt' => new lang_string('cfg_iconalt_go', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/stop', - 'alt' => new lang_string('cfg_iconalt_stop', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/user', - 'alt' => new lang_string('cfg_iconalt_user', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'u/user35', - 'alt' => new lang_string('cfg_iconalt_user2', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/permissions', - 'alt' => new lang_string('cfg_iconalt_key', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/cohort', - 'alt' => new lang_string('cfg_iconalt_group', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/groups', - 'alt' => new lang_string('cfg_iconalt_group2', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/mnethost', - 'alt' => new lang_string('cfg_iconalt_mnet', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'microsoft', + 'alt' => new lang_string('cfg_iconalt_microsoft', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 'i/permissionlock', - 'alt' => new lang_string('cfg_iconalt_userlock', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'microsoft_365_copilot', + 'alt' => new lang_string('cfg_iconalt_microsoft365copilot', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 't/more', - 'alt' => new lang_string('cfg_iconalt_plus', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'office_365', + 'alt' => new lang_string('cfg_iconalt_o365', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 't/approve', - 'alt' => new lang_string('cfg_iconalt_check', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'openid', + 'alt' => new lang_string('cfg_iconalt_openid', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 't/right', - 'alt' => new lang_string('cfg_iconalt_rightarrow', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'keycloak', + 'alt' => new lang_string('cfg_iconalt_keycloak', 'auth_oidc'), + 'component' => 'auth_oidc', ], ]; $settings->add( @@ -759,7 +714,7 @@ 'auth_oidc/icon', get_string('cfg_icon_key', 'auth_oidc'), get_string('cfg_icon_desc', 'auth_oidc'), - 'auth_oidc:o365', + 'auth_oidc:microsoft_365', $icons ) ); @@ -773,7 +728,13 @@ get_string('cfg_customicon_desc', 'auth_oidc'), 'customicon', 0, - ['accepted_types' => ['.png', '.jpg', '.ico'], 'maxbytes' => get_max_upload_file_size()] + [ + 'accepted_types' => array_map( + fn ($extension) => ".{$extension}", + AUTH_OIDC_CUSTOMICON_ALLOWED_EXTENSIONS + ), + 'maxbytes' => get_max_upload_file_size(), + ] ); $customiconsetting->set_updatedcallback('auth_oidc_initialize_customicon'); $settings->add($customiconsetting); diff --git a/version.php b/version.php index cb0064cef..80043372f 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.05; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From 0d30a0f7a3e5c9562ec454908642b6964663e95d Mon Sep 17 00:00:00 2001 From: Patryk Mroczko Date: Thu, 6 Aug 2026 11:49:18 +0200 Subject: [PATCH 19/36] Fix OIDC login CSRF by verifying sesskey on callback --- classes/loginflow/authcode.php | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..b0c6f69bf 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -264,6 +264,7 @@ public function initiateauthrequest( array $extraparams = [], bool $selectaccount = false ) { + $this->set_csrf_cookie(); $client = $this->get_oidcclient(); $client->authrequest($promptlogin, $stateparams, $extraparams, $selectaccount); } @@ -276,10 +277,74 @@ public function initiateauthrequest( * @return void */ public function initiateadminconsentrequest(array $stateparams = [], array $extraparams = []) { + $this->set_csrf_cookie(); $client = $this->get_oidcclient(); $client->adminconsentrequest($stateparams, $extraparams); } + /** + * Set a dedicated CSRF cookie (SameSite=None; Secure) before redirecting to the IdP. + * + * The main session cookie carries SameSite=Lax (MDL-83526), which browsers drop on + * cross-site form_post callbacks, so a separate cookie is required to carry the sesskey. + */ + protected function set_csrf_cookie(): void { + global $CFG; + // Always attempt to set the cookie with the Secure flag: browsers on plain HTTP will + // simply refuse to store it (safe no-op), while HTTPS terminated by a reverse proxy that + // Moodle isn't aware of (missing $CFG->sslproxy) will still honour it correctly. + $cookiepath = parse_url($CFG->wwwroot, PHP_URL_PATH) ?: '/'; + setcookie('auth_oidc_csrf', sesskey(), [ + 'expires' => time() + 5 * MINSECS, + 'path' => $cookiepath, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'None', + ]); + } + + /** + * Clear the CSRF cookie set by set_csrf_cookie(). + */ + protected function clear_csrf_cookie(): void { + global $CFG; + setcookie('auth_oidc_csrf', '', [ + 'expires' => time() - HOURSECS, + 'path' => parse_url($CFG->wwwroot, PHP_URL_PATH) ?: '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'None', + ]); + } + + /** + * Verify the state record's sesskey against the CSRF cookie, falling back to the current + * session's sesskey when the cookie is absent. Always clears the cookie and, on failure, + * deletes the state record. + * + * @param stdClass $staterec + * @throws moodle_exception if the CSRF check fails. + */ + protected function verify_csrf_cookie(stdClass $staterec): void { + global $DB; + + $csrfcookie = $_COOKIE['auth_oidc_csrf'] ?? null; + $csrftoken = (is_string($csrfcookie) && $csrfcookie !== '') ? $csrfcookie : sesskey(); + $valid = hash_equals((string) $staterec->sesskey, (string) $csrftoken); + + $this->clear_csrf_cookie(); + + if (!$valid) { + $DB->delete_records('auth_oidc_state', ['id' => $staterec->id]); + utils::debug( + $csrfcookie === null ? 'CSRF cookie missing on OIDC callback.' : 'CSRF cookie mismatch on OIDC callback.', + __METHOD__, + ['staterecid' => $staterec->id] + ); + throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); + } + } + /** * Handles the response for certificate-based admin consent authorization. * @@ -306,6 +371,10 @@ protected function handlecertadminconsentresponse(array $authparams) { throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); } + if (is_https() || !empty($CFG->sslproxy) || !empty($_COOKIE['auth_oidc_csrf'])) { + $this->verify_csrf_cookie($staterec); + } + $orignonce = $staterec->nonce; $additionaldata = []; if (!empty($staterec->additionaldata)) { @@ -369,6 +438,10 @@ protected function handleauthresponse(array $authparams) { throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); } + if (is_https() || !empty($CFG->sslproxy) || !empty($_COOKIE['auth_oidc_csrf'])) { + $this->verify_csrf_cookie($staterec); + } + $orignonce = $staterec->nonce; $additionaldata = []; if (!empty($staterec->additionaldata)) { From 4fcdfb4acd44bde99ca0072a68381656fc4002ee Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Tue, 11 Aug 2026 10:13:25 +0100 Subject: [PATCH 20/36] Fix OIDC single sign-out breaking due to SameSite cookie changes in Moodle core --- classes/loginflow/authcode.php | 6 ++ classes/privacy/provider.php | 2 + classes/task/cleanup_oidc_sid.php | 26 +++++++- db/install.xml | 5 ++ db/upgrade.php | 31 ++++++++++ lang/en/auth_oidc.php | 2 + logout.php | 35 ++++++++--- tests/task/cleanup_oidc_sid_test.php | 93 +++++++++++++++------------- version.php | 2 +- 9 files changed, 149 insertions(+), 53 deletions(-) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..f0097edb3 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -466,6 +466,12 @@ protected function handleauthresponse(array $authparams) { $authoidsidrecord->userid = $USER->id; $authoidsidrecord->sid = $sid; $authoidsidrecord->timecreated = time(); + // Store the Moodle session id so logout.php can terminate the correct session directly, + // without depending on the MoodleSession cookie being present on the IdP's logout request. + $authoidsidrecord->sessionid = session_id(); + // Store the issuer so logout.php can verify that a front-channel logout request naming + // this sid actually originates from the same IdP/tenant that issued it. + $authoidsidrecord->iss = $idtoken->claim('iss'); $DB->insert_record('auth_oidc_sid', $authoidsidrecord); } redirect(core_login_get_return_url()); diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 2dda27996..fd79912cc 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -73,6 +73,8 @@ public static function get_metadata(collection $collection): collection { 'userid', 'sid', 'timecreated', + 'sessionid', + 'iss', ], ]; diff --git a/classes/task/cleanup_oidc_sid.php b/classes/task/cleanup_oidc_sid.php index b6ed866a1..b9e5047ed 100644 --- a/classes/task/cleanup_oidc_sid.php +++ b/classes/task/cleanup_oidc_sid.php @@ -25,6 +25,7 @@ namespace auth_oidc\task; +use core\session\manager; use core\task\scheduled_task; /** @@ -40,10 +41,33 @@ public function get_name() { /** * Clean up OIDC SID records. + * + * A mapping is only removed once its Moodle session no longer exists, rather than after a fixed + * time period, so that SSO logout keeps working for sessions that outlive that fixed period. */ public function execute() { global $DB; - $DB->delete_records_select('auth_oidc_sid', 'timecreated < ?', [strtotime('-1 day')]); + // Legacy mappings with no recorded session id (created before sessionid was tracked) can never + // be confirmed to have a live session, so delete them directly without a session_exists() check. + $DB->delete_records_select('auth_oidc_sid', 'sessionid IS NULL OR sessionid = ?', ['']); + + // Fetch only the columns needed to decide what to keep, all into memory up front: issuing + // further queries (session_exists, delete_records) while an unbuffered recordset cursor is open + // can trigger "commands out of sync" errors on some DB drivers. + $records = $DB->get_records('auth_oidc_sid', null, '', 'id, sessionid'); + + $staleids = []; + foreach ($records as $record) { + if (!manager::session_exists($record->sessionid)) { + $staleids[] = $record->id; + } + } + + // Delete in chunks to avoid hitting parameter/query-length limits on some DB drivers if a large + // number of mappings have accumulated between cleanup runs. + foreach (array_chunk($staleids, 1000) as $chunk) { + $DB->delete_records_list('auth_oidc_sid', 'id', $chunk); + } } } diff --git a/db/install.xml b/db/install.xml index 038f48779..29a1d6108 100644 --- a/db/install.xml +++ b/db/install.xml @@ -67,10 +67,15 @@ + + + + +
diff --git a/db/upgrade.php b/db/upgrade.php index c4d776684..e35e8e81e 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -586,6 +586,37 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025100601.01, 'auth', 'oidc'); } + if ($oldversion < 2026042000.06) { + // Define field sessionid to be added to auth_oidc_sid. + $table = new xmldb_table('auth_oidc_sid'); + $field = new xmldb_field('sessionid', XMLDB_TYPE_CHAR, '128', null, null, null, null, 'timecreated'); + + // Conditionally launch add field sessionid. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define index sid to be added to auth_oidc_sid. + $index = new xmldb_index('sid', XMLDB_INDEX_NOTUNIQUE, ['sid']); + + // Conditionally launch add index sid. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Define field iss to be added to auth_oidc_sid. + $table = new xmldb_table('auth_oidc_sid'); + $field = new xmldb_field('iss', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'sessionid'); + + // Conditionally launch add field iss. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Oidc savepoint reached. + upgrade_plugin_savepoint(true, 2026042000.06, 'auth', 'oidc'); + } + return true; } diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..7e960f031 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -320,6 +320,8 @@ $string['privacy:metadata:auth_oidc_sid:userid'] = 'The ID of the Moodle user'; $string['privacy:metadata:auth_oidc_sid:sid'] = 'The IdP session identifier (sid)'; $string['privacy:metadata:auth_oidc_sid:timecreated'] = 'The time when the mapping was created'; +$string['privacy:metadata:auth_oidc_sid:sessionid'] = 'The Moodle session id active at the time the mapping was created'; +$string['privacy:metadata:auth_oidc_sid:iss'] = 'The issuer (iss claim) of the id_token the mapping was created from'; // In the following strings, $a refers to a customizable name for the identity manager. For example, this could be // "Microsoft 365", "OpenID Connect", etc. diff --git a/logout.php b/logout.php index bdbd30a94..bbf8d6413 100644 --- a/logout.php +++ b/logout.php @@ -24,6 +24,7 @@ */ use core\context\system; +use core\session\manager; // phpcs:ignore moodle.Files.RequireLogin.Missing require_once(__DIR__ . '/../../config.php'); @@ -32,19 +33,35 @@ $PAGE->set_context(system::instance()); $sid = optional_param('sid', '', PARAM_TEXT); +$iss = optional_param('iss', '', PARAM_TEXT); if ($sid) { - if ($authoidcsidrecord = $DB->get_record('auth_oidc_sid', ['sid' => $sid])) { - if ($authoidcsidrecord->userid == $USER->id) { - $authsequence = get_enabled_auth_plugins(); // Auths, in sequence. - foreach ($authsequence as $authname) { - $authplugin = get_auth_plugin($authname); - $authplugin->logoutpage_hook(); - } + // This request is made by the IdP directly (e.g. via a hidden iframe), so it will not carry the + // MoodleSession cookie of the user(s) being logged out, and there is no way to authenticate it. + // Do not call auth plugin logout hooks here: this is an unauthenticated endpoint, so anyone could + // trigger them merely by supplying a known sid. + $conditions = ['sid' => $sid]; + if ($iss) { + // When the IdP includes iss, use it as an extra check that the mapping was created for this + // issuer, so a sid alone (without also knowing the issuer it was created for) cannot be used to + // force a logout. Not all IdPs include iss on front-channel logout requests (e.g. Azure AD + // currently does not), so this is applied only when present rather than required. + $conditions['iss'] = $iss; + } - $DB->delete_records('auth_oidc_sid', ['sid' => $sid]); - require_logout(); + $authoidcsidrecords = $DB->get_records('auth_oidc_sid', $conditions); + if ($authoidcsidrecords) { + // The same IdP sid can be mapped to more than one Moodle session (e.g. logins from different + // browsers/devices during the same IdP session), so destroy every session mapped to this sid. + $matchedids = []; + foreach ($authoidcsidrecords as $authoidcsidrecord) { + if (!empty($authoidcsidrecord->sessionid)) { + manager::destroy($authoidcsidrecord->sessionid); + } + $matchedids[] = $authoidcsidrecord->id; } + + $DB->delete_records_list('auth_oidc_sid', 'id', $matchedids); } } diff --git a/tests/task/cleanup_oidc_sid_test.php b/tests/task/cleanup_oidc_sid_test.php index 9a49b8688..6e8fe7c67 100644 --- a/tests/task/cleanup_oidc_sid_test.php +++ b/tests/task/cleanup_oidc_sid_test.php @@ -32,64 +32,73 @@ */ final class cleanup_oidc_sid_test extends advanced_testcase { /** - * SIDs older than 1 day are deleted. + * Insert a row into the core {sessions} table so that + * \core\session\manager::session_exists() reports it as existing. * - * The cleanup task deletes records where timecreated < strtotime('-1 day'). - * Records created exactly 1 day ago or more recently are kept. + * @param string $sid + * @param int $userid + * @return void + * @throws dml_exception + */ + private function create_moodle_session(string $sid, int $userid): void { + global $DB; + + $DB->insert_record('sessions', [ + 'state' => 0, + 'sid' => $sid, + 'userid' => $userid, + 'sessdata' => '', + 'timecreated' => time(), + 'timemodified' => time(), + ]); + } + + /** + * Mappings whose Moodle session still exists are kept; mappings whose session no longer exists, or + * that were never given a session id, are deleted. * * @return void * @throws dml_exception * @covers ::execute */ - public function test_sids_older_than_yesterday_are_deleted(): void { + public function test_sid_records_are_cleaned_up_based_on_session_existence(): void { global $DB; $this->resetAfterTest(); - // Create a test user to own the SID records. $user = $this->getDataGenerator()->create_user(); - // 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); + // Mapping tied to a session that still exists: must be kept. + $this->create_moodle_session('session_active', $user->id); + $activeid = $DB->insert_record('auth_oidc_sid', [ + 'userid' => $user->id, + 'sid' => 'sid_active', + 'timecreated' => time(), + 'sessionid' => 'session_active', + ]); - // 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. + // Mapping tied to a session id that does not exist (e.g. the user's session has already + // expired or been terminated some other way): must be deleted. + $expiredid = $DB->insert_record('auth_oidc_sid', [ + 'userid' => $user->id, + 'sid' => 'sid_expired', + 'timecreated' => time(), + 'sessionid' => 'session_does_not_exist', + ]); - // Create entries in auth_oidc_sid with unique SIDs. - $entry1id = $DB->insert_record( - 'auth_oidc_sid', - ['userid' => $user->id, 'sid' => 'sid_old_1', 'timecreated' => $twodaysago], - ); - $entry2id = $DB->insert_record( - 'auth_oidc_sid', - ['userid' => $user->id, 'sid' => 'sid_old_2', 'timecreated' => $beforecutoff], - ); - $entry3id = $DB->insert_record( - 'auth_oidc_sid', - ['userid' => $user->id, 'sid' => 'sid_new_1', 'timecreated' => $muchlater], - ); - $entry4id = $DB->insert_record( - 'auth_oidc_sid', - ['userid' => $user->id, 'sid' => 'sid_new_2', 'timecreated' => $aftercutoff], - ); + // Legacy mapping created before sessionid was tracked: must be deleted, since there is no way + // to confirm whether its session still exists. + $legacyid = $DB->insert_record('auth_oidc_sid', [ + 'userid' => $user->id, + 'sid' => 'sid_legacy', + 'timecreated' => time(), + 'sessionid' => null, + ]); $cleanup = new cleanup_oidc_sid(); - $cleanup->execute(); - $records = $DB->get_records('auth_oidc_sid'); - - $this->assertCount(2, $records); - - $this->assertTrue($DB->record_exists('auth_oidc_sid', ['id' => $entry3id])); - $this->assertFalse($DB->record_exists('auth_oidc_sid', ['id' => $entry1id])); - $this->assertFalse($DB->record_exists('auth_oidc_sid', ['id' => $entry2id])); - $this->assertTrue($DB->record_exists('auth_oidc_sid', ['id' => $entry4id])); + $this->assertTrue($DB->record_exists('auth_oidc_sid', ['id' => $activeid])); + $this->assertFalse($DB->record_exists('auth_oidc_sid', ['id' => $expiredid])); + $this->assertFalse($DB->record_exists('auth_oidc_sid', ['id' => $legacyid])); } } diff --git a/version.php b/version.php index cb0064cef..17a54cf12 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.06; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From ec47c7fb9d26645cdcd31e294f8bcab9231af468 Mon Sep 17 00:00:00 2001 From: Patryk Mroczko Date: Fri, 14 Aug 2026 13:08:42 +0200 Subject: [PATCH 21/36] Warn when "Custom" binding username claim becomes unsupported --- lang/en/auth_oidc.php | 1 + lib.php | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..d00f6b229 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -461,6 +461,7 @@
  • custom: Custom claim.
  • '; $string['binding_username_claim_updated'] = 'Binding username claim was updated successfully.'; +$string['warning_binding_username_claim_custom_unsupported'] = 'The "Binding username claim" setting is currently set to "Custom", which is not supported for the configured IdP type with user sync enabled. Review the Binding username claim settings.'; $string['examplecsv'] = 'Example upload file'; $string['usernamefile'] = 'File'; $string['csvdelimiter'] = 'CSV separator'; diff --git a/lib.php b/lib.php index 5d13a5ae0..318aa66da 100644 --- a/lib.php +++ b/lib.php @@ -133,6 +133,8 @@ function auth_oidc_reset_app_tokens($settingname) { * @return void */ function auth_oidc_validate_auth_settings(string $settingname) { + auth_oidc_validate_binding_username_claim(); + $idptype = get_config('auth_oidc', 'idptype'); $clientauthmethod = get_config('auth_oidc', 'clientauthmethod'); @@ -196,6 +198,27 @@ function auth_oidc_validate_auth_settings(string $settingname) { } } +/** + * Warn the admin if the stored "Custom" binding username claim is no longer supported for the + * currently configured IdP type and user sync setting. + * + * @return void + */ +function auth_oidc_validate_binding_username_claim() { + $idptype = get_config('auth_oidc', 'idptype'); + if (empty($idptype) || get_config('auth_oidc', 'bindingusernameclaim') !== 'custom') { + return; + } + + $mstypes = [AUTH_OIDC_IDP_TYPE_MICROSOFT_ENTRA_ID, AUTH_OIDC_IDP_TYPE_MICROSOFT_IDENTITY_PLATFORM]; + if (in_array($idptype, $mstypes) && auth_oidc_is_local_365_installed() && auth_oidc_is_user_sync_enabled()) { + $bindingclaimurl = new url('/admin/settings.php', ['section' => 'auth_oidc_binding_username_claim']); + \core\notification::warning( + get_string('warning_binding_username_claim_custom_unsupported', 'auth_oidc', $bindingclaimurl->out()) + ); + } +} + /** * Initialize custom icon for OIDC authentication. * From d229b90a217e92ddcd166330bd8907db5f28d979 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 5 Aug 2026 21:57:27 +0100 Subject: [PATCH 22/36] Update precision of sid column in auth_oidc_sid table --- db/install.xml | 4 ++-- db/upgrade.php | 26 ++++++++++++++++++++++++++ version.php | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/db/install.xml b/db/install.xml index 038f48779..e7c509543 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -65,7 +65,7 @@ - + diff --git a/db/upgrade.php b/db/upgrade.php index c4d776684..bbdbbbeb7 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -586,6 +586,32 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025100601.01, 'auth', 'oidc'); } + if ($oldversion < 2026042000.04) { + // Widen the sid field on auth_oidc_sid: it stores the session_state parameter, which can + // exceed 36 characters (e.g. two GUIDs joined by a dot), not a fixed-length identifier. + $table = new xmldb_table('auth_oidc_sid'); + $field = new xmldb_field('sid', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null, 'userid'); + $index = new xmldb_index('sid', XMLDB_INDEX_NOTUNIQUE, ['sid']); + + if ($dbman->field_exists($table, $field)) { + // The sid index depends on this field, so the DDL layer refuses to alter it in + // place (ddl_dependency_exception); drop the index first and recreate it once the + // field has been widened. + if ($dbman->index_exists($table, $index)) { + $dbman->drop_index($table, $index); + } + + $dbman->change_field_precision($table, $field); + + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + } + + // Oidc savepoint reached. + upgrade_plugin_savepoint(true, 2026042000.04, 'auth', 'oidc'); + } + return true; } diff --git a/version.php b/version.php index cb0064cef..d07b68fd4 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.04; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From f0c6c8fb577840cac6715076e07bad561a1b0fc2 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Fri, 7 Aug 2026 18:12:28 +0100 Subject: [PATCH 23/36] Update icon settings in auth_oidc --- classes/adminsetting/iconselect.css | 1 + classes/loginflow/authcode.php | 8 ++- classes/utils.php | 96 ++++++++++++++++++++++++++++ db/upgrade.php | 5 ++ lang/en/auth_oidc.php | 23 ++----- lib.php | 31 ++++++++- pix/keycloak.png | Bin 0 -> 10506 bytes pix/microsoft.svg | 1 + pix/microsoft_365.svg | 34 ++++++++++ pix/microsoft_365_copilot.svg | 51 +++++++++++++++ pix/o365.png | Bin 497 -> 0 bytes pix/office_365.svg | 1 + pix/openid.svg | 31 +++++++++ settings.php | 89 ++++++++------------------ version.php | 2 +- 15 files changed, 288 insertions(+), 85 deletions(-) create mode 100644 pix/keycloak.png create mode 100644 pix/microsoft.svg create mode 100644 pix/microsoft_365.svg create mode 100644 pix/microsoft_365_copilot.svg delete mode 100644 pix/o365.png create mode 100644 pix/office_365.svg create mode 100644 pix/openid.svg diff --git a/classes/adminsetting/iconselect.css b/classes/adminsetting/iconselect.css index 6fe4121d4..d55e20f75 100644 --- a/classes/adminsetting/iconselect.css +++ b/classes/adminsetting/iconselect.css @@ -7,6 +7,7 @@ label.iconselect img { width: 25px; height: 25px; padding: 10px; + margin-right: 0; } input.iconselect { display: none; diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b12867dca..438e16efe 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -59,25 +59,27 @@ public function loginpage_idp_list($wantsurl) { return []; } $showicon = isset($this->config->set_pix) ? $this->config->set_pix : true; + $name = strip_tags(format_text($this->config->opname)); $idpentry = [ 'url' => new url('/auth/oidc/', ['source' => 'loginpage']), - 'name' => strip_tags(format_text($this->config->opname)), + 'name' => $name, ]; if ($showicon) { if (!empty($this->config->customicon)) { $iconvalue = 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 = (!empty($this->config->icon)) ? $this->config->icon : 'auth_oidc:microsoft_365'; $icon = explode(':', $icon); if (isset($icon[1])) { [$iconcomponent, $iconname] = $icon; } else { $iconcomponent = 'auth_oidc'; - $iconname = 'o365'; + $iconname = 'microsoft_365'; } $iconvalue = new pix_icon($iconname, get_string('pluginname', 'auth_oidc'), $iconcomponent); } $idpentry['icon'] = $iconvalue; + $idpentry['name'] = $name; } return [$idpentry]; } diff --git a/classes/utils.php b/classes/utils.php index 5dbaf86dd..2cf09c415 100644 --- a/classes/utils.php +++ b/classes/utils.php @@ -28,6 +28,7 @@ use Exception; use moodle_exception; use auth_oidc\event\action_failed; +use core\context\system; use core\url; /** @@ -252,4 +253,99 @@ public static function get_openssl_internal_path() { return $CFG->dataroot . '/microsoft_certs'; } + + /** + * Migrate a site's selected stock icon to the custom icon setting if it used one of the + * icon choices that have been removed from the icon selector. + * + * The 'auth_oidc/icon' setting stores a "component:pix" identifier. The set of stock + * choices has been reduced to a handful of icons relevant to this plugin; any site that had + * selected one of the removed choices (all generic core Moodle icons) needs that icon copied + * into the custom icon file area so the login page keeps showing the same image. + * + * Safe to call more than once: once a site has been migrated (or its 'icon' setting was + * never one of the removed choices), every subsequent call is a no-op, since the checks + * above always return early once either 'auth_oidc/icon' is empty/unset or + * 'auth_oidc/customicon' is populated. The file and config writes are wrapped in a + * delegated transaction so a failure partway through can't leave those two settings out of + * sync with each other, which is what the early-return checks rely on. + */ + public static function migrate_removed_icon_choices(): void { + global $CFG, $DB; + + $currenticon = get_config('auth_oidc', 'icon'); + if (empty($currenticon)) { + return; + } + + if (!empty(get_config('auth_oidc', 'customicon'))) { + // A custom icon is already in use and takes priority, so the stock icon setting is + // not currently affecting what is displayed. Nothing to migrate. + return; + } + + // The old default icon has simply been replaced with a new image under a new pix name. + if ($currenticon === 'auth_oidc:o365') { + set_config('icon', 'auth_oidc:office_365', 'auth_oidc'); + return; + } + + $keepicons = [ + 'auth_oidc:microsoft_365', + 'auth_oidc:microsoft', + 'auth_oidc:microsoft_365_copilot', + 'auth_oidc:office_365', + 'auth_oidc:openid', + 'auth_oidc:keycloak', + ]; + if (in_array($currenticon, $keepicons, true)) { + return; + } + + $parts = explode(':', $currenticon, 2); + if (count($parts) !== 2) { + return; + } + [, $pix] = $parts; + + $sourcefile = null; + $extension = null; + foreach (['svg', 'png', 'gif', 'jpg', 'jpeg'] as $candidateextension) { + $candidatefile = "{$CFG->dirroot}/pix/{$pix}.{$candidateextension}"; + if (file_exists($candidatefile)) { + $sourcefile = $candidatefile; + $extension = $candidateextension; + break; + } + } + if ($sourcefile === null) { + // Can't locate the source image for the removed choice, so there is nothing to copy. + return; + } + + $systemcontext = system::instance(); + $fs = get_file_storage(); + $filename = 'migrated_' . clean_param(str_replace('/', '_', $pix), PARAM_FILE) . '.' . $extension; + $filerecord = [ + 'contextid' => $systemcontext->id, + 'component' => 'auth_oidc', + 'filearea' => 'customicon', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + ]; + + // Wrapped in a transaction so a failure partway through (e.g. the file write succeeding + // but a config write failing) can't leave 'icon' and 'customicon' out of sync, which + // would break the early-return guards above on any later call. + $transaction = $DB->start_delegated_transaction(); + $fs->delete_area_files($systemcontext->id, 'auth_oidc', 'customicon', 0); + $fs->create_file_from_pathname($filerecord, $sourcefile); + set_config('customicon', '/' . $filename, 'auth_oidc'); + unset_config('icon', 'auth_oidc'); + $transaction->allow_commit(); + + require_once($CFG->dirroot . '/auth/oidc/lib.php'); + auth_oidc_initialize_customicon('/' . $filename); + } } diff --git a/db/upgrade.php b/db/upgrade.php index c4d776684..e75396392 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -586,6 +586,11 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025100601.01, 'auth', 'oidc'); } + if ($oldversion < 2026042000.05) { + \auth_oidc\utils::migrate_removed_icon_choices(); + upgrade_plugin_savepoint(true, 2026042000.05, 'auth', 'oidc'); + } + return true; } diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..5b6ff5b97 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -119,23 +119,14 @@ $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'; -$string['cfg_iconalt_locked'] = 'Locked icon'; -$string['cfg_iconalt_lock'] = 'Lock icon'; -$string['cfg_iconalt_go'] = 'Green circle'; -$string['cfg_iconalt_stop'] = 'Red circle'; -$string['cfg_iconalt_user'] = 'User icon'; -$string['cfg_iconalt_user2'] = 'User icon alternate'; -$string['cfg_iconalt_key'] = 'Key icon'; -$string['cfg_iconalt_group'] = 'Group icon'; -$string['cfg_iconalt_group2'] = 'Group icon alternate'; -$string['cfg_iconalt_mnet'] = 'MNET icon'; -$string['cfg_iconalt_userlock'] = 'User with lock icon'; -$string['cfg_iconalt_plus'] = 'Plus icon'; -$string['cfg_iconalt_check'] = 'Checkmark icon'; -$string['cfg_iconalt_rightarrow'] = 'Right-facing arrow icon'; +$string['cfg_iconalt_o365'] = 'Office 365 icon'; +$string['cfg_iconalt_microsoft365'] = 'Microsoft 365 icon'; +$string['cfg_iconalt_microsoft'] = 'Microsoft icon'; +$string['cfg_iconalt_microsoft365copilot'] = 'Microsoft 365 Copilot icon'; +$string['cfg_iconalt_openid'] = 'OpenID icon'; +$string['cfg_iconalt_keycloak'] = 'Keycloak icon'; $string['cfg_customicon_key'] = 'Custom Icon'; -$string['cfg_customicon_desc'] = 'If you\'d like to use your own icon, upload it here. This overrides any icon chosen above.

    Notes on using custom icons:
    • This image will not be resized on the login page, so we recommend uploading an image no bigger than 35x35 pixels.
    • If you have uploaded a custom icon and want to go back to one of the stock icons, click the custom icon in the box above, then click "Delete", then click "OK", then click "Save Changes" at the bottom of this form. The selected stock icon will now appear on the Moodle login page.
    '; +$string['cfg_customicon_desc'] = 'If you\'d like to use your own icon, upload it here. This overrides any icon chosen above.

    Notes on using custom icons:
    • The uploaded file is not resized. It will be displayed at a fixed size of 24x24 pixels on the login page, with your browser scaling it to fit, so we recommend uploading a square image to avoid distortion.
    • If you have uploaded a custom icon and want to go back to one of the stock icons, click the custom icon in the box above, then click "Delete", then click "OK", then click "Save Changes" at the bottom of this form. The selected stock icon will now appear on the Moodle login page.
    '; $string['cfg_debugmode_key'] = 'Record debug messages'; $string['cfg_debugmode_desc'] = 'If enabled, information will be logged to the Moodle log that can help in identifying problems.'; $string['cfg_loginflow_key'] = 'Login Flow'; diff --git a/lib.php b/lib.php index 5d13a5ae0..e7eab9f73 100644 --- a/lib.php +++ b/lib.php @@ -84,6 +84,17 @@ */ const AUTH_OIDC_AUTH_CERT_SOURCE_FILE = 2; +/** + * File extensions accepted for the 'auth_oidc/customicon' upload setting. + * + * SVG is deliberately excluded: unlike the plugin's own bundled stock icons, this file is + * admin-uploaded and served as-is from dataroot, so allowing SVG here would let an admin + * upload active content (script/event handlers). Shared by the setting's file picker + * (settings.php) and the extension allow-list checked before copying the file into + * pix_plugins (auth_oidc_initialize_customicon()) so the two can't drift apart. + */ +const AUTH_OIDC_CUSTOMICON_ALLOWED_EXTENSIONS = ['png', 'jpg', 'gif']; + /** * Callback invoked when application credentials or endpoint settings are updated. * @@ -227,7 +238,25 @@ function auth_oidc_initialize_customicon($filefullname) { } if (file_exists($CFG->dataroot . '/pix_plugins/auth/oidc/0')) { - $file->copy_content_to($CFG->dataroot . '/pix_plugins/auth/oidc/0/customicon.jpg'); + // Remove any previously stored custom icon so a stale file with a different + // extension can't take priority when the theme resolves the icon image. + $oldiconfiles = glob($CFG->dataroot . '/pix_plugins/auth/oidc/0/customicon.*'); + foreach ($oldiconfiles ?: [] as $oldiconfile) { + // A failed unlink (e.g. permissions, or the file already being gone) isn't fatal + // here: copy_content_to() below will still overwrite/create the current extension's + // file, so at worst a stale file of a different extension is left behind. + @unlink($oldiconfile); + } + + $extension = strtolower(pathinfo($file->get_filename(), PATHINFO_EXTENSION)); + if (!in_array($extension, AUTH_OIDC_CUSTOMICON_ALLOWED_EXTENSIONS, true)) { + // Unexpected/empty extension: don't create a weird or unvalidated file under + // pix_plugins. The stale files for previously-valid extensions were already + // removed above, so this leaves no custom icon in place. + return false; + } + + $file->copy_content_to($CFG->dataroot . "/pix_plugins/auth/oidc/0/customicon.{$extension}"); theme_reset_all_caches(); } } diff --git a/pix/keycloak.png b/pix/keycloak.png new file mode 100644 index 0000000000000000000000000000000000000000..48e18430cb06664c10a32008e13dd7b02ab3d879 GIT binary patch literal 10506 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 \ No newline at end of file diff --git a/pix/microsoft_365.svg b/pix/microsoft_365.svg new file mode 100644 index 000000000..53fc9a444 --- /dev/null +++ b/pix/microsoft_365.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pix/microsoft_365_copilot.svg b/pix/microsoft_365_copilot.svg new file mode 100644 index 000000000..cf32dbde5 --- /dev/null +++ b/pix/microsoft_365_copilot.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pix/o365.png b/pix/o365.png deleted file mode 100644 index 0fa022c25ea9ba897202f1fb751f64b8b9529227..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 497 zcmVB?)x<+801dA@%ak@$jjB)&yV2q%8!~BFqC` zz&qW#8SMlJf6p&Y&LfUxAuJ?-NEP!l@Cdk7dM%~GDHX<(6%!+`%DxJ-70^3iTVcr& zhTJ3CMcJqqy)yAK&`}N#i`H`3cdX`zD<<{0zExDHH=|p1-8JOYA8CLa;071~g(nr( zR2cj#xcsD5Sap&BA-AP*Q33>IU-;9!F6@7Iq$ z=Si2s9FPS1eeEI;s$zR~Ga9W4u2eQABM3=>86fIwKLC3`IuvHN=RofRgUbhuOOx}; ngjEs(S7O=eJ>d*h>!1As!hwLQcNwtj00000NkvXXu0mjfzc$O& diff --git a/pix/office_365.svg b/pix/office_365.svg new file mode 100644 index 000000000..66e8725af --- /dev/null +++ b/pix/office_365.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pix/openid.svg b/pix/openid.svg new file mode 100644 index 000000000..63a90e05c --- /dev/null +++ b/pix/openid.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/settings.php b/settings.php index 2c1d9774a..c2ce94ca8 100644 --- a/settings.php +++ b/settings.php @@ -679,79 +679,34 @@ // Icon. $icons = [ [ - 'pix' => 'o365', - 'alt' => new lang_string('cfg_iconalt_o365', 'auth_oidc'), + 'pix' => 'microsoft_365', + 'alt' => new lang_string('cfg_iconalt_microsoft365', 'auth_oidc'), 'component' => 'auth_oidc', ], [ - 'pix' => 't/locked', - 'alt' => new lang_string('cfg_iconalt_locked', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/lock', - 'alt' => new lang_string('cfg_iconalt_lock', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/go', - 'alt' => new lang_string('cfg_iconalt_go', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/stop', - 'alt' => new lang_string('cfg_iconalt_stop', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 't/user', - 'alt' => new lang_string('cfg_iconalt_user', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'u/user35', - 'alt' => new lang_string('cfg_iconalt_user2', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/permissions', - 'alt' => new lang_string('cfg_iconalt_key', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/cohort', - 'alt' => new lang_string('cfg_iconalt_group', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/groups', - 'alt' => new lang_string('cfg_iconalt_group2', 'auth_oidc'), - 'component' => 'moodle', - ], - [ - 'pix' => 'i/mnethost', - 'alt' => new lang_string('cfg_iconalt_mnet', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'microsoft', + 'alt' => new lang_string('cfg_iconalt_microsoft', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 'i/permissionlock', - 'alt' => new lang_string('cfg_iconalt_userlock', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'microsoft_365_copilot', + 'alt' => new lang_string('cfg_iconalt_microsoft365copilot', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 't/more', - 'alt' => new lang_string('cfg_iconalt_plus', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'office_365', + 'alt' => new lang_string('cfg_iconalt_o365', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 't/approve', - 'alt' => new lang_string('cfg_iconalt_check', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'openid', + 'alt' => new lang_string('cfg_iconalt_openid', 'auth_oidc'), + 'component' => 'auth_oidc', ], [ - 'pix' => 't/right', - 'alt' => new lang_string('cfg_iconalt_rightarrow', 'auth_oidc'), - 'component' => 'moodle', + 'pix' => 'keycloak', + 'alt' => new lang_string('cfg_iconalt_keycloak', 'auth_oidc'), + 'component' => 'auth_oidc', ], ]; $settings->add( @@ -759,7 +714,7 @@ 'auth_oidc/icon', get_string('cfg_icon_key', 'auth_oidc'), get_string('cfg_icon_desc', 'auth_oidc'), - 'auth_oidc:o365', + 'auth_oidc:microsoft_365', $icons ) ); @@ -773,7 +728,13 @@ get_string('cfg_customicon_desc', 'auth_oidc'), 'customicon', 0, - ['accepted_types' => ['.png', '.jpg', '.ico'], 'maxbytes' => get_max_upload_file_size()] + [ + 'accepted_types' => array_map( + fn ($extension) => ".{$extension}", + AUTH_OIDC_CUSTOMICON_ALLOWED_EXTENSIONS + ), + 'maxbytes' => get_max_upload_file_size(), + ] ); $customiconsetting->set_updatedcallback('auth_oidc_initialize_customicon'); $settings->add($customiconsetting); diff --git a/version.php b/version.php index cb0064cef..80043372f 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.05; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From b8059ce4dc0e0a448cf2bcca23c2eda68be0afdd Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 19 Aug 2026 16:51:07 +0100 Subject: [PATCH 24/36] Hide secret expiry notification heading when its only setting is hidden The "Secret expiry notification" section heading was always shown once local_o365 is installed, even when its only field (secret expiry recipients) is JS-hidden because the client authentication method isn't Secret or the IdP type is Other. This left an empty section with just a heading and the page's Save button above it. Apply the same hide_if() conditions to the heading so it hides together with the field it introduces. --- settings.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/settings.php b/settings.php index 2c1d9774a..299e01475 100644 --- a/settings.php +++ b/settings.php @@ -384,6 +384,21 @@ 'eq', AUTH_OIDC_IDP_TYPE_OTHER ); + + // Hide the section heading too, so an empty section isn't shown when the only + // setting in it is hidden. + $applicationsettings->hide_if( + 'auth_oidc/application_secretexpiry_heading', + 'auth_oidc/clientauthmethod', + 'neq', + AUTH_OIDC_AUTH_METHOD_SECRET + ); + $applicationsettings->hide_if( + 'auth_oidc/application_secretexpiry_heading', + 'auth_oidc/idptype', + 'eq', + AUTH_OIDC_IDP_TYPE_OTHER + ); } $ADMIN->add('oidcfolder', $applicationsettings); From e923798a42b13322ea35dff27b6be1849ef08f19 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Mon, 24 Aug 2026 11:54:20 +0100 Subject: [PATCH 25/36] Pin GitHub Actions to full-length commit SHAs --- .github/dependabot.yml | 11 +++++++++++ .github/workflows/ci.yml | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c48305b7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + groups: + github-actions: + patterns: ["*"] + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce8a6cbb2..9ba75e467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,12 @@ jobs: steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: plugin - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} ini-values: max_input_vars=5000 From bf4b1e4941e13e553a6408fac258dda6ce252a91 Mon Sep 17 00:00:00 2001 From: Mark Nelson Date: Tue, 25 Aug 2026 14:22:57 +0100 Subject: [PATCH 26/36] Add missing auth_settings_validation_error lang string auth_oidc_validate_auth_settings() in lib.php (added in a13728f, 'Re-organise configuration pages for auth_oidc and local_o365...') calls get_string('auth_settings_validation_error', 'auth_oidc') to build the notification shown when settings validation fails, but the corresponding lang string was never added. Cherry-picked from microsoft/moodle-auth_oidc#70 (2c8b686ece4dba3a5a4a9ea47e5382c25291a54c) --- lang/en/auth_oidc.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..1be8a95c2 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -31,6 +31,7 @@ $string['pluginname'] = 'OpenID Connect'; $string['auth_oidcdescription'] = 'The OpenID Connect authentication plugin provides single-sign-on functionality using configurable IdP.'; +$string['auth_settings_validation_error'] = 'There are problems with the OpenID Connect settings:'; // Configuration pages. $string['settings_page_other_settings'] = 'Other options'; From 4544bde9d8908f9d465a57b1f324eadab70f1a34 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Tue, 25 Aug 2026 15:58:13 +0100 Subject: [PATCH 27/36] Remove duplicate lang strings in auth_oidc Bring in line with wip-136768-m405: drop the auth_settings_validation_error added directly after auth_oidcdescription (from microsoft/moodle-auth_oidc#70) and the stale error_masked_secret_not_changed wording that predates the "Change" checkbox UX, replacing both with the current wording already used on m405, in the error-strings block after error_invalid_custom_claim. Also removes a pre-existing upstream duplicate, errorauthdisconnectinvalidmethod (added twice, identically, in 6f8e9528 back in 2015). Harmless since both copies had the same value, but redundant. --- lang/en/auth_oidc.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index 1be8a95c2..2ed27bf54 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -31,7 +31,6 @@ $string['pluginname'] = 'OpenID Connect'; $string['auth_oidcdescription'] = 'The OpenID Connect authentication plugin provides single-sign-on functionality using configurable IdP.'; -$string['auth_settings_validation_error'] = 'There are problems with the OpenID Connect settings:'; // Configuration pages. $string['settings_page_other_settings'] = 'Other options'; @@ -112,7 +111,6 @@ $string['cfg_err_invalidauthendpoint'] = 'Invalid Authorization Endpoint'; $string['cfg_err_invalidtokenendpoint'] = 'Invalid Token Endpoint'; $string['cfg_err_invalidclientid'] = 'Invalid client ID'; -$string['error_masked_secret_not_changed'] = 'Please enter a new value. The masked value cannot be saved.'; $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'; @@ -237,7 +235,6 @@ $string['errorauthdisconnectnewmethod'] = 'Use Login Method'; $string['errorauthdisconnectinvalidmethod'] = 'Invalid login method received.'; $string['errorauthdisconnectifmanual'] = 'If using the manual login method, enter credentials below.'; -$string['errorauthdisconnectinvalidmethod'] = 'Invalid login method received.'; $string['errorauthgeneral'] = 'There was a problem logging you in. Please contact your administrator for assistance.'; $string['errorauthinvalididtoken'] = 'Invalid id_token received.'; $string['errorauthloginfailednouser'] = 'Invalid login: User not found in Moodle. If this site has the "authpreventaccountcreation" setting enabled, this may mean you need an administrator to create an account for you first.'; @@ -284,6 +281,8 @@ $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['error_masked_secret_not_changed'] = 'Please enter a new value or uncheck the "Change" checkbox to keep the current value.'; +$string['auth_settings_validation_error'] = 'Invalid authentication settings detected. The following configuration issues must be resolved to ensure successful authentication:'; $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.'; From addfadd22830a3c277239e89a79fb04bd33a9c28 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 26 Aug 2026 16:01:25 +0100 Subject: [PATCH 28/36] Fix admin consent redirecting to login screen after SameSite=Lax drops session Microsoft's admin-consent and add-tenant callbacks can arrive via a cross-site navigation that drops the session cookie under SameSite=Lax, kicking the initiating admin back to the login screen instead of completing the flow. Capture the initiating user's id in the OIDC state and re-establish their session on the callback when none is currently active. Hardening applied on top of the base fix, per code review: - Only re-login when there is no active session (or it's a guest), never switching away an already-logged-in different user. - Don't clobber a caller-provided initiatinguserid already present in the state params. - Require sesskey on mode_adminconsent() and mode_tenantsaddgo() so the privileged flow can't be CSRF-initiated. - Invalidate any pending OIDC CSRF state (state row + cookie) on logout, so an explicit logout mid-flow can't be undone by a later callback. - Only allow the initiatinguserid auto-login when CSRF-cookie verification actually ran, closing an unauthenticated-login gap on non-HTTPS deployments without the CSRF cookie. --- classes/loginflow/authcode.php | 42 +++++++++++++++++++++++++++++++--- classes/observers.php | 22 ++++++++++++++++++ db/events.php | 6 +++++ version.php | 2 +- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index b0c6f69bf..d2004eb46 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -277,6 +277,12 @@ public function initiateauthrequest( * @return void */ public function initiateadminconsentrequest(array $stateparams = [], array $extraparams = []) { + global $USER; + + if (!isset($stateparams['initiatinguserid']) && isloggedin() && !isguestuser()) { + $stateparams['initiatinguserid'] = $USER->id; + } + $this->set_csrf_cookie(); $client = $this->get_oidcclient(); $client->adminconsentrequest($stateparams, $extraparams); @@ -305,8 +311,11 @@ protected function set_csrf_cookie(): void { /** * Clear the CSRF cookie set by set_csrf_cookie(). + * + * Public so that \auth_oidc\observers::handle_user_loggedout() can clear a stale cookie + * when a user logs out while an OIDC request is still pending. */ - protected function clear_csrf_cookie(): void { + public function clear_csrf_cookie(): void { global $CFG; setcookie('auth_oidc_csrf', '', [ 'expires' => time() - HOURSECS, @@ -371,7 +380,8 @@ protected function handlecertadminconsentresponse(array $authparams) { throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); } - if (is_https() || !empty($CFG->sslproxy) || !empty($_COOKIE['auth_oidc_csrf'])) { + $csrfverified = is_https() || !empty($CFG->sslproxy) || !empty($_COOKIE['auth_oidc_csrf']); + if ($csrfverified) { $this->verify_csrf_cookie($staterec); } @@ -403,6 +413,16 @@ protected function handlecertadminconsentresponse(array $authparams) { $event = user_authed::create($eventdata); $event->trigger(); + if ($csrfverified && !empty($additionaldata['initiatinguserid'])) { + $initiatinguser = core_user::get_user((int) $additionaldata['initiatinguserid']); + if ( + $initiatinguser && empty($initiatinguser->deleted) && + (!isloggedin() || isguestuser()) + ) { + \core\session\manager::login_user($initiatinguser); + } + } + $redirect = (!empty($additionaldata['redirect'])) ? $additionaldata['redirect'] : '/auth/oidc/ucp.php'; redirect(new url($redirect)); } @@ -438,7 +458,8 @@ protected function handleauthresponse(array $authparams) { throw new moodle_exception('errorauthunknownstate', 'auth_oidc'); } - if (is_https() || !empty($CFG->sslproxy) || !empty($_COOKIE['auth_oidc_csrf'])) { + $csrfverified = is_https() || !empty($CFG->sslproxy) || !empty($_COOKIE['auth_oidc_csrf']); + if ($csrfverified) { $this->verify_csrf_cookie($staterec); } @@ -482,6 +503,21 @@ protected function handleauthresponse(array $authparams) { ]; $event = user_authed::create($eventdata); $event->trigger(); + + if ($csrfverified && !empty($additionaldata['initiatinguserid'])) { + $initiatinguser = core_user::get_user((int) $additionaldata['initiatinguserid']); + if ( + $initiatinguser && empty($initiatinguser->deleted) && + (!isloggedin() || isguestuser()) + ) { + \core\session\manager::login_user($initiatinguser); + } + } + + if (!empty($additionaldata['redirect'])) { + redirect(new url($additionaldata['redirect'])); + } + return true; } diff --git a/classes/observers.php b/classes/observers.php index 9f715e511..e11917cd4 100644 --- a/classes/observers.php +++ b/classes/observers.php @@ -25,6 +25,7 @@ namespace auth_oidc; +use auth_oidc\loginflow\authcode; use core\event\user_deleted; use core\event\user_loggedout; @@ -47,4 +48,25 @@ public static function handle_user_deleted(user_deleted $event) { $userid = $event->objectid; return $DB->delete_records('auth_oidc_token', ['userid' => $userid]); } + + /** + * Handle user_loggedout event - invalidate any pending OIDC state tied to the CSRF cookie + * of the session that just logged out, so a still-in-flight callback (e.g. admin consent) + * cannot silently re-authenticate a user who explicitly logged out. + * + * @param user_loggedout $event The triggered event. + * @return bool Success/Failure. + */ + public static function handle_user_loggedout(user_loggedout $event) { + global $DB; + + $csrfcookie = $_COOKIE['auth_oidc_csrf'] ?? null; + if (is_string($csrfcookie) && $csrfcookie !== '') { + $DB->delete_records('auth_oidc_state', ['sesskey' => $csrfcookie]); + } + + (new authcode())->clear_csrf_cookie(); + + return true; + } } diff --git a/db/events.php b/db/events.php index 376c05a7f..0013df416 100644 --- a/db/events.php +++ b/db/events.php @@ -32,4 +32,10 @@ 'priority' => 200, 'internal' => false, ], + [ + 'eventname' => '\core\event\user_loggedout', + 'callback' => '\auth_oidc\observers::handle_user_loggedout', + 'priority' => 200, + 'internal' => false, + ], ]; diff --git a/version.php b/version.php index cb0064cef..330508e1c 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.07; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From 43abf4c9dec3e6f953455b0c7b38bd8bc614e728 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 26 Aug 2026 16:16:44 +0100 Subject: [PATCH 29/36] Fix warning message display for giving admin consent using Microsoft v2 IdP When the auth_oidc plugin is configured to use Microsoft identity platform (v2) IdP, the "Microsoft Entra tenant" and "OneDrive for Business URL" settings need to be manual filled after providing admin consent. The local_o365 plugin settings page displays a message about it, but this is invisible unless debugging is turned on. This commit changes how the message is displayed so it's more obvious. --- classes/loginflow/authcode.php | 37 +++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index d2004eb46..2df0bdd09 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -396,9 +396,32 @@ protected function handlecertadminconsentresponse(array $authparams) { $SESSION->stateadditionaldata = $additionaldata; $DB->delete_records('auth_oidc_state', ['id' => $staterec->id]); - // Get token. + // Re-associate the browser session with the admin who initiated the consent request before doing + // anything that might fail below, so a failure in the auto-detection step (see below) does not leave + // the admin looking logged out on the resulting page. + if ($csrfverified && !empty($additionaldata['initiatinguserid'])) { + $initiatinguser = core_user::get_user((int) $additionaldata['initiatinguserid']); + if ( + $initiatinguser && empty($initiatinguser->deleted) && + (!isloggedin() || isguestuser()) + ) { + \core\session\manager::login_user($initiatinguser); + } + } + + // Get token. This app-only token is only used to auto-detect the Microsoft Entra tenant and OneDrive for + // Business URL settings below; admin consent itself has already been granted by Microsoft Entra by this + // point. Conditional Access policies can block this specific app-only token request (AADSTS53003) even + // though consent succeeded, so that failure is treated as a recoverable warning rather than a fatal error. $client = $this->get_oidcclient(); - $tokenparams = $client->app_access_token_request(); + try { + $tokenparams = $client->app_access_token_request(); + } catch (moodle_exception $e) { + if ($e->errorcode === 'settings_adminconsent_error_53003' && $e->module === 'local_o365') { + redirect($e->link, $e->getMessage(), null, notification::NOTIFY_WARNING); + } + throw $e; + } if (!isset($tokenparams['access_token'])) { throw new moodle_exception('errorauthnoaccesstoken', 'auth_oidc'); } @@ -413,16 +436,6 @@ protected function handlecertadminconsentresponse(array $authparams) { $event = user_authed::create($eventdata); $event->trigger(); - if ($csrfverified && !empty($additionaldata['initiatinguserid'])) { - $initiatinguser = core_user::get_user((int) $additionaldata['initiatinguserid']); - if ( - $initiatinguser && empty($initiatinguser->deleted) && - (!isloggedin() || isguestuser()) - ) { - \core\session\manager::login_user($initiatinguser); - } - } - $redirect = (!empty($additionaldata['redirect'])) ? $additionaldata['redirect'] : '/auth/oidc/ucp.php'; redirect(new url($redirect)); } From 50b641bdfcc480870e047c6421dbe2dd052d91c0 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 26 Aug 2026 16:16:44 +0100 Subject: [PATCH 30/36] Fix admin consent failing when app-only token request is blocked by Conditional Access When the auth_oidc plugin is configured to use Microsoft identity platform (v2.0) IdP, an app-only access token is requested right after admin consent to auto-detect the "Microsoft Entra tenant" and "OneDrive for Business URL" settings. Conditional Access policies can block this specific token request (AADSTS53003) even though admin consent itself succeeded, which previously surfaced as a fatal error page instead of returning the admin to the settings page. This commit catches that specific failure and silently redirects back to the local_o365 settings page instead of throwing a fatal error, since the tenant and OneDrive for Business URL can still be auto-detected via other Graph API calls, so no warning is needed. The re-association of the browser session with the admin who initiated consent is also moved earlier, before the token request, so a failure there no longer leaves the admin looking logged out on the resulting page. Also removes the now-unused local_o365 language string "settings_adminconsent_error_53003". --- classes/loginflow/authcode.php | 38 +++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/classes/loginflow/authcode.php b/classes/loginflow/authcode.php index d2004eb46..69d7c0029 100644 --- a/classes/loginflow/authcode.php +++ b/classes/loginflow/authcode.php @@ -396,9 +396,33 @@ protected function handlecertadminconsentresponse(array $authparams) { $SESSION->stateadditionaldata = $additionaldata; $DB->delete_records('auth_oidc_state', ['id' => $staterec->id]); - // Get token. + // Re-associate the browser session with the admin who initiated the consent request before doing + // anything that might fail below, so a failure in the auto-detection step (see below) does not leave + // the admin looking logged out on the resulting page. + if ($csrfverified && !empty($additionaldata['initiatinguserid'])) { + $initiatinguser = core_user::get_user((int) $additionaldata['initiatinguserid']); + if ( + $initiatinguser && empty($initiatinguser->deleted) && + (!isloggedin() || isguestuser()) + ) { + \core\session\manager::login_user($initiatinguser); + } + } + + // Get token. This app-only token is only used to auto-detect the Microsoft Entra tenant and OneDrive for + // Business URL settings below; admin consent itself has already been granted by Microsoft Entra by this + // point. Conditional Access policies can block this specific app-only token request (AADSTS53003) even + // though consent succeeded, and the tenant/URL can still be auto-detected via other Graph API calls, so + // that failure is not fatal and is silently redirected past. $client = $this->get_oidcclient(); - $tokenparams = $client->app_access_token_request(); + try { + $tokenparams = $client->app_access_token_request(); + } catch (moodle_exception $e) { + if ($e->errorcode === 'settings_adminconsent_error_53003' && $e->module === 'local_o365') { + redirect($e->link); + } + throw $e; + } if (!isset($tokenparams['access_token'])) { throw new moodle_exception('errorauthnoaccesstoken', 'auth_oidc'); } @@ -413,16 +437,6 @@ protected function handlecertadminconsentresponse(array $authparams) { $event = user_authed::create($eventdata); $event->trigger(); - if ($csrfverified && !empty($additionaldata['initiatinguserid'])) { - $initiatinguser = core_user::get_user((int) $additionaldata['initiatinguserid']); - if ( - $initiatinguser && empty($initiatinguser->deleted) && - (!isloggedin() || isguestuser()) - ) { - \core\session\manager::login_user($initiatinguser); - } - } - $redirect = (!empty($additionaldata['redirect'])) ? $additionaldata['redirect'] : '/auth/oidc/ucp.php'; redirect(new url($redirect)); } From 3eace2e4db668c22caf62576c51e3278e8ebf042 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Mon, 31 Aug 2026 13:09:28 +0100 Subject: [PATCH 31/36] Make secret expiry notification heading hideable via hide_if() The fix in the previous commit applied hide_if() to the section heading, but core's admin_setting_heading renders only a bare

    with no named form control and no .form-item wrapper, so showhidesettings.js cannot target it and the condition was silently ignored. The heading stayed visible with an empty section whenever the client authentication method wasn't Secret or the IdP type was Other. Add auth_oidc_admin_setting_section_heading, which wraps the heading in a .form-item container with a hidden input carrying the setting's form field name, so the admin settings show/hide JavaScript can hide it together with the field it introduces. --- ...uth_oidc_admin_setting_section_heading.php | 60 +++++++++++++++++++ settings.php | 3 +- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 classes/adminsetting/auth_oidc_admin_setting_section_heading.php diff --git a/classes/adminsetting/auth_oidc_admin_setting_section_heading.php b/classes/adminsetting/auth_oidc_admin_setting_section_heading.php new file mode 100644 index 000000000..f2a0c69e1 --- /dev/null +++ b/classes/adminsetting/auth_oidc_admin_setting_section_heading.php @@ -0,0 +1,60 @@ +. + +/** + * Definition of a section heading admin setting that can be hidden with hide_if(). + * + * @package auth_oidc + * @author Lai Wei + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright (C) 2021 onwards Microsoft, Inc. (http://microsoft.com/) + */ + +namespace auth_oidc\adminsetting; + +use admin_setting_heading; +use html_writer; + +/** + * A section heading that participates in admin settings hide_if() dependencies. + * + * The core {@see admin_setting_heading} renders only a bare

    , with no named + * form control and no .form-item wrapper, so the admin settings show/hide + * JavaScript (lib/amd/src/showhidesettings.js) cannot target it and any + * hide_if() condition applied to it is silently ignored. This subclass wraps the + * heading in a .form-item container and adds a hidden input carrying the + * setting's form field name, so the heading is shown and hidden together with + * the settings it introduces. + */ +class auth_oidc_admin_setting_section_heading extends admin_setting_heading { + /** + * Output the heading wrapped so that hide_if() dependencies can act on it. + * + * @param mixed $data + * @param string $query + * @return string + */ + public function output_html($data, $query = '') { + $heading = parent::output_html($data, $query); + $hiddeninput = html_writer::empty_tag('input', [ + 'type' => 'hidden', + 'name' => 's_' . $this->plugin . '_' . $this->name, + 'value' => 1, + ]); + + return html_writer::div($hiddeninput . $heading, 'form-item', ['id' => 'admin-' . $this->name]); + } +} diff --git a/settings.php b/settings.php index 299e01475..5780dd704 100644 --- a/settings.php +++ b/settings.php @@ -30,6 +30,7 @@ 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\adminsetting\auth_oidc_admin_setting_section_heading; use auth_oidc\utils; use core\url; @@ -281,7 +282,7 @@ // Secret expiry notification (only when local_o365 is installed). if (auth_oidc_is_local_365_installed()) { - $applicationsettings->add(new admin_setting_heading( + $applicationsettings->add(new auth_oidc_admin_setting_section_heading( 'auth_oidc/application_secretexpiry_heading', get_string('settings_section_secret_expiry_notification', 'auth_oidc'), '' From 534b0318a1320bf3613ead1d0fabacccc8a101e7 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Mon, 31 Aug 2026 13:40:34 +0100 Subject: [PATCH 32/36] Validate secret expiry notification recipients and check deliverability auth_oidc: reject email addresses that are not valid in the "secret expiry notification recipients" setting when it is saved, both on the admin settings page (new auth_oidc_admin_setting_secretexpiryrecipients class) and in the application configuration wizard form. local_o365: notifysecretexpiry now DNS-checks each configured recipient domain (MX, falling back to A/AAAA) before sending, skips recipients that cannot receive mail, and marks the task as failed so the misconfiguration is visible to admins instead of the address being silently dropped. (cherry picked from commit 975552ce8d3880be725cbfcbccd8cba6307b3cee) --- ...c_admin_setting_secretexpiryrecipients.php | 59 +++++++++++++++ classes/form/application.php | 9 +++ lang/en/auth_oidc.php | 1 + lib.php | 26 +++++++ settings.php | 3 +- tests/lib_test.php | 75 +++++++++++++++++++ version.php | 2 +- 7 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 classes/adminsetting/auth_oidc_admin_setting_secretexpiryrecipients.php create mode 100644 tests/lib_test.php diff --git a/classes/adminsetting/auth_oidc_admin_setting_secretexpiryrecipients.php b/classes/adminsetting/auth_oidc_admin_setting_secretexpiryrecipients.php new file mode 100644 index 000000000..b7ec94c98 --- /dev/null +++ b/classes/adminsetting/auth_oidc_admin_setting_secretexpiryrecipients.php @@ -0,0 +1,59 @@ +. + +/** + * Admin setting class for the secret expiry notification recipients setting. + * + * @package auth_oidc + * @author Lai Wei + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright (C) 2026 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 the comma-separated list of secret expiry notification recipients. + * + * Extends the standard text setting with validation that rejects the value when any entry is + * not a valid email address, so the local_o365 notifysecretexpiry task is not left silently + * dropping recipients at run time. + */ +class auth_oidc_admin_setting_secretexpiryrecipients extends \admin_setting_configtext { + /** + * Validate the submitted list of recipient email addresses. + * + * @param string $data The submitted 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; + } + + $invalidemails = auth_oidc_validate_secret_expiry_recipients((string) $data); + if ($invalidemails) { + return get_string('error_secretexpiryrecipients_invalid', 'auth_oidc', implode(', ', $invalidemails)); + } + + return true; + } +} diff --git a/classes/form/application.php b/classes/form/application.php index 98b695a39..c0a00cb7f 100644 --- a/classes/form/application.php +++ b/classes/form/application.php @@ -397,6 +397,15 @@ public function validation($data, $files) { } } + // Validate secret expiry notification recipients (only relevant with secret auth). + if (isset($data['secretexpiryrecipients']) && $data['clientauthmethod'] == AUTH_OIDC_AUTH_METHOD_SECRET) { + $invalidemails = auth_oidc_validate_secret_expiry_recipients((string) $data['secretexpiryrecipients']); + if ($invalidemails) { + $errors['secretexpiryrecipients'] = + get_string('error_secretexpiryrecipients_invalid', 'auth_oidc', implode(', ', $invalidemails)); + } + } + return $errors; } diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index c6031d7e0..b492ea13c 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -283,6 +283,7 @@ $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['error_secretexpiryrecipients_invalid'] = 'The following secret expiry notification recipients are not valid email addresses: {$a}'; $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.'; diff --git a/lib.php b/lib.php index 5d13a5ae0..a99f18baa 100644 --- a/lib.php +++ b/lib.php @@ -1041,6 +1041,32 @@ function auth_oidc_mask_secret($secret) { return substr($secret, 0, 2) . '**********'; } +/** + * Validate the "secret expiry notification recipients" setting value. + * + * The value is a comma-separated list of email addresses that the local_o365 notifysecretexpiry + * task sends notifications to. Empty entries and surrounding whitespace are ignored, and an empty + * list is valid (notifications then fall back to the site administrator). Only the syntax of each + * address is checked; the domain is not resolved. + * + * @param string $value The raw setting value. + * @return array List of entries that are not valid email addresses. Empty when every entry is valid. + */ +function auth_oidc_validate_secret_expiry_recipients(string $value): array { + $invalidemails = []; + foreach (explode(',', $value) as $email) { + $email = trim($email); + if ($email === '') { + continue; + } + if (!validate_email($email)) { + $invalidemails[] = $email; + } + } + + return $invalidemails; +} + /** * Check if a value appears to be a masked secret. * diff --git a/settings.php b/settings.php index 2c1d9774a..d825aa3ae 100644 --- a/settings.php +++ b/settings.php @@ -30,6 +30,7 @@ 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\adminsetting\auth_oidc_admin_setting_secretexpiryrecipients; use auth_oidc\utils; use core\url; @@ -287,7 +288,7 @@ '' )); - $applicationsettings->add(new admin_setting_configtext( + $applicationsettings->add(new auth_oidc_admin_setting_secretexpiryrecipients( 'auth_oidc/secretexpiryrecipients', get_string('secretexpiryrecipients', 'auth_oidc'), get_string('secretexpiryrecipients_help', 'auth_oidc'), diff --git a/tests/lib_test.php b/tests/lib_test.php new file mode 100644 index 000000000..339ae7a46 --- /dev/null +++ b/tests/lib_test.php @@ -0,0 +1,75 @@ +. + +namespace auth_oidc; + +use advanced_testcase; + +/** + * Unit tests for functions in auth/oidc/lib.php + * + * @package auth_oidc + * @author Lai Wei + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright (C) 2026 onwards Microsoft, Inc. (http://microsoft.com/) + * @group auth_oidc + * @group office365 + */ +final class lib_test extends advanced_testcase { + /** + * Data provider for {@see self::test_validate_secret_expiry_recipients()}. + * + * @return array + */ + public static function validate_secret_expiry_recipients_provider(): array { + return [ + 'empty string' => ['', []], + 'whitespace only' => [' ', []], + 'single valid address' => ['admin@example.com', []], + 'multiple valid addresses' => ['admin@example.com, second@example.com', []], + 'valid addresses with surrounding whitespace and trailing comma' => [ + ' admin@example.com , second@example.com , ', + [], + ], + 'single invalid address' => ['not-an-email', ['not-an-email']], + 'mixed valid and invalid' => [ + 'admin@example.com, broken, third@example.com', + ['broken'], + ], + 'multiple invalid addresses' => [ + 'broken, also broken@', + ['broken', 'also broken@'], + ], + ]; + } + + /** + * Test auth_oidc_validate_secret_expiry_recipients(). + * + * @dataProvider validate_secret_expiry_recipients_provider + * @param string $value + * @param array $expected + * @return void + * @covers ::auth_oidc_validate_secret_expiry_recipients + */ + public function test_validate_secret_expiry_recipients(string $value, array $expected): void { + $this->resetAfterTest(true); + + require_once(__DIR__ . '/../lib.php'); + + $this->assertSame($expected, auth_oidc_validate_secret_expiry_recipients($value)); + } +} diff --git a/version.php b/version.php index cb0064cef..7f86e3a78 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000; +$plugin->version = 2026042000.08; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From 5734eadd33ec3cc10b85443dc087b88c98cfe4de Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Tue, 1 Sep 2026 12:46:24 +0100 Subject: [PATCH 33/36] Update GitHub actions configuration files --- .github/dependabot.yml | 2 +- .github/workflows/ci.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c48305b7..d0d482aa3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,7 @@ updates: directory: "/" groups: github-actions: - patterns: ["*"] + patterns: [ "*" ] schedule: interval: "weekly" cooldown: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba75e467..eea04b937 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,9 +40,9 @@ jobs: strategy: fail-fast: false matrix: - moodle-branch: ['MOODLE_502_STABLE'] - php: [8.3, 8.4] - database: [pgsql, mariadb] + moodle-branch: [ 'MOODLE_502_STABLE' ] + php: [ 8.3, 8.4 ] + database: [ pgsql, mariadb ] steps: - name: Check out repository code From 3a7d54c5deea0363af757fa05867858d498372b0 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Tue, 1 Sep 2026 14:18:25 +0100 Subject: [PATCH 34/36] Fix CI workflow dependency handling and clean up service config - Root ci.yml: rebuild matrix internal-deps from each plugin's version.php dependencies (including transitive ones); drop the no-op bare include entries. - Check out all dependency folders via sparse-checkout and move whole directories (keeping dotfiles) into extra-plugins; fail the job if a declared dependency is missing. - Drop MYSQL_USER=root and the MySQL env vars the mariadb image ignores; pin mariadb:10 -> mariadb:10.11. - Add "permissions: contents: read" to every workflow. - Sync per-plugin workflow templates to actions/checkout v6.1.0. --- .github/workflows/ci.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eea04b937..410d69804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: - 'MOODLE_*_STABLE' pull_request: +permissions: + contents: read + jobs: check: runs-on: ubuntu-latest @@ -27,12 +30,7 @@ jobs: mariadb: image: mariadb:10.11 env: - MYSQL_USER: 'root' MYSQL_ALLOW_EMPTY_PASSWORD: "true" - MYSQL_CHARACTER_SET_SERVER: "utf8mb4" - MYSQL_COLLATION_SERVER: "utf8mb4_unicode_ci" - MYSQL_INNODB_FILE_PER_TABLE: "1" - MYSQL_INNODB_FILE_FORMAT: "Barracuda" ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 @@ -46,7 +44,7 @@ jobs: steps: - name: Check out repository code - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: path: plugin From e0297e526159f3c5519c289782acfc59af2d6783 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 2 Sep 2026 09:38:23 +0100 Subject: [PATCH 35/36] Use single Microsoft 365 logo for auth_oidc login icon Remove the separate microsoft_365 and microsoft_365_copilot icon images. The current microsoft.svg logo is now used for both Microsoft and Microsoft 365, renamed to microsoft_365.svg and referred to as the Microsoft 365 logo. Sites that had selected the Microsoft or Microsoft 365 Copilot icon are migrated to the Microsoft 365 logo on upgrade. --- classes/utils.php | 15 +++++++---- db/upgrade.php | 5 ++++ lang/en/auth_oidc.php | 4 +-- pix/microsoft.svg | 1 - pix/microsoft_365.svg | 35 +----------------------- pix/microsoft_365_copilot.svg | 51 ----------------------------------- settings.php | 10 ------- version.php | 2 +- 8 files changed, 18 insertions(+), 105 deletions(-) delete mode 100644 pix/microsoft.svg delete mode 100644 pix/microsoft_365_copilot.svg diff --git a/classes/utils.php b/classes/utils.php index 2cf09c415..dd937d305 100644 --- a/classes/utils.php +++ b/classes/utils.php @@ -284,16 +284,21 @@ public static function migrate_removed_icon_choices(): void { return; } - // The old default icon has simply been replaced with a new image under a new pix name. - if ($currenticon === 'auth_oidc:o365') { - set_config('icon', 'auth_oidc:office_365', 'auth_oidc'); + // Icons that have simply been replaced with another stock icon: the choice was removed + // from the selector but a suitable replacement exists, so just point the setting at it. + $remappedicons = [ + 'auth_oidc:o365' => 'auth_oidc:office_365', + // The Microsoft 365 logo is now the single icon used for both Microsoft and Microsoft 365. + 'auth_oidc:microsoft' => 'auth_oidc:microsoft_365', + 'auth_oidc:microsoft_365_copilot' => 'auth_oidc:microsoft_365', + ]; + if (isset($remappedicons[$currenticon])) { + set_config('icon', $remappedicons[$currenticon], 'auth_oidc'); return; } $keepicons = [ 'auth_oidc:microsoft_365', - 'auth_oidc:microsoft', - 'auth_oidc:microsoft_365_copilot', 'auth_oidc:office_365', 'auth_oidc:openid', 'auth_oidc:keycloak', diff --git a/db/upgrade.php b/db/upgrade.php index e75396392..a6203ded9 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -591,6 +591,11 @@ function xmldb_auth_oidc_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2026042000.05, 'auth', 'oidc'); } + if ($oldversion < 2026042000.08) { + \auth_oidc\utils::migrate_removed_icon_choices(); + upgrade_plugin_savepoint(true, 2026042000.08, 'auth', 'oidc'); + } + return true; } diff --git a/lang/en/auth_oidc.php b/lang/en/auth_oidc.php index 5b6ff5b97..dc38dc0b1 100644 --- a/lang/en/auth_oidc.php +++ b/lang/en/auth_oidc.php @@ -120,9 +120,7 @@ $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'] = 'Office 365 icon'; -$string['cfg_iconalt_microsoft365'] = 'Microsoft 365 icon'; -$string['cfg_iconalt_microsoft'] = 'Microsoft icon'; -$string['cfg_iconalt_microsoft365copilot'] = 'Microsoft 365 Copilot icon'; +$string['cfg_iconalt_microsoft365'] = 'Microsoft 365 logo'; $string['cfg_iconalt_openid'] = 'OpenID icon'; $string['cfg_iconalt_keycloak'] = 'Keycloak icon'; $string['cfg_customicon_key'] = 'Custom Icon'; diff --git a/pix/microsoft.svg b/pix/microsoft.svg deleted file mode 100644 index 5334aa7ca..000000000 --- a/pix/microsoft.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/pix/microsoft_365.svg b/pix/microsoft_365.svg index 53fc9a444..5334aa7ca 100644 --- a/pix/microsoft_365.svg +++ b/pix/microsoft_365.svg @@ -1,34 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/pix/microsoft_365_copilot.svg b/pix/microsoft_365_copilot.svg deleted file mode 100644 index cf32dbde5..000000000 --- a/pix/microsoft_365_copilot.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/settings.php b/settings.php index c2ce94ca8..4aebbab7e 100644 --- a/settings.php +++ b/settings.php @@ -683,16 +683,6 @@ 'alt' => new lang_string('cfg_iconalt_microsoft365', 'auth_oidc'), 'component' => 'auth_oidc', ], - [ - 'pix' => 'microsoft', - 'alt' => new lang_string('cfg_iconalt_microsoft', 'auth_oidc'), - 'component' => 'auth_oidc', - ], - [ - 'pix' => 'microsoft_365_copilot', - 'alt' => new lang_string('cfg_iconalt_microsoft365copilot', 'auth_oidc'), - 'component' => 'auth_oidc', - ], [ 'pix' => 'office_365', 'alt' => new lang_string('cfg_iconalt_o365', 'auth_oidc'), diff --git a/version.php b/version.php index 80043372f..7f86e3a78 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000.05; +$plugin->version = 2026042000.08; $plugin->requires = 2026042000; $plugin->release = '5.2.0'; $plugin->component = 'auth_oidc'; From 79f2ad7357ed12b731cecb113f07c53ddc826ccb Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Tue, 1 Sep 2026 16:45:17 +0100 Subject: [PATCH 36/36] Update plugin versions for 5.2.1 release --- version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.php b/version.php index 7f86e3a78..43242e981 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026042000.08; +$plugin->version = 2026042001; $plugin->requires = 2026042000; -$plugin->release = '5.2.0'; +$plugin->release = '5.2.1'; $plugin->component = 'auth_oidc'; $plugin->maturity = MATURITY_STABLE;