From d63721cb15544480dd60d2deaec12d6f975b0dfe Mon Sep 17 00:00:00 2001
From: Jake Dallimore <jake@moodle.com>
Date: Tue, 30 Jan 2024 12:14:17 +0800
Subject: [PATCH 1/6] MDL-80835 auth_lti: add cookie helper facilitating CHIPS
 opt-in

To opt a cookie in to Chrome's 3rd party cookie partitioning solution,
CHIPS, the property 'Partitioned;' needs to be set. This adds a helper
class supporting this, for a given cookie(s).

Note also, PHP's native
cookie APIs (setcookie, etc) don't support this cookie property yet -
(https://github.com/php/php-src/issues/12646).

Since this class is intended to allow existing Set-Cookie headers to be
modified before being sent (e.g. allowing clients to set a property on a
cookie set elsewhere in code), it deals with the headers directly anyway
but it means that new cookies must also use this helper to opt-in,
instead of relying on setcookie(). E.g. where the intent is to add
partitioning support to a new cookie, that cookie must first be set
(setcookie) and then it may opt-in to partitioning via this helper;
partitioning support cannot be achieved directly through setcookie and
friends yet.
---
 .../ltiadvantage/utility/cookie_helper.php    | 295 ++++++++++++++++++
 .../utility/cookie_helper_test.php            | 215 +++++++++++++
 2 files changed, 510 insertions(+)
 create mode 100644 auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php
 create mode 100644 auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php

diff --git a/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php b/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php
new file mode 100644
index 00000000000..4a1d6189b48
--- /dev/null
+++ b/auth/lti/classes/local/ltiadvantage/utility/cookie_helper.php
@@ -0,0 +1,295 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace auth_lti\local\ltiadvantage\utility;
+
+/**
+ * Helper class providing utils dealing with cookies in LTI, particularly 3rd party cookies.
+ *
+ * This class primarily provides a means to augment outbound cookie headers, in order to satisfy browser-specific
+ * requirements for setting 3rd party cookies.
+ *
+ * @package    auth_lti
+ * @copyright  2024 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+final class cookie_helper {
+
+    /** @var int Cookies are not supported. */
+    public const COOKIE_METHOD_NOT_SUPPORTED = 0;
+
+    /** @var int Cookies are supported without explicit partitioning. */
+    public const COOKIE_METHOD_NO_PARTITIONING = 1;
+
+    /** @var int Cookies are supported via explicit partitioning. */
+    public const COOKIE_METHOD_EXPLICIT_PARTITIONING = 2;
+
+    /**
+     * Make sure the given attributes are set on the Set-Cookie response header identified by name=$cookiename.
+     *
+     * This function only affects Set-Cookie headers and modifies the headers directly with the required changes, if any.
+     *
+     * @param string $cookiename the cookie name.
+     * @param array $attributes the attributes to set/ensure are set.
+     * @return void
+     */
+    public static function add_attributes_to_cookie_response_header(string $cookiename, array $attributes): void {
+
+        $setcookieheaders = array_filter(headers_list(), function($val) {
+            return preg_match("/Set-Cookie:/i", $val);
+        });
+        if (empty($setcookieheaders)) {
+            return;
+        }
+
+        $updatedheaders = self::cookie_response_headers_add_attributes($setcookieheaders, [$cookiename], $attributes);
+
+        // Note: The header_remove() method is quite crude and removes all headers of that header name.
+        header_remove('Set-Cookie');
+        foreach ($updatedheaders as $header) {
+            header($header, false);
+        }
+    }
+
+    /**
+     * Given a list of HTTP header strings, return a list of HTTP header strings where the matched 'Set-Cookie' headers
+     * have been updated with the attributes defined in $attribute - an array of strings.
+     *
+     * This method does not verify whether a given attribute is valid or not. It blindly sets it and returns the header
+     * strings. It's up to calling code to determine whether an attribute makes sense or not.
+     *
+     * @param array $headerstrings the array of header strings.
+     * @param array $cookiestomatch the array of cookie names to match.
+     * @param array $attributes the attributes to set on each matched 'Set-Cookie' header.
+     * @param bool $casesensitive whether to match the attribute in a case-sensitive way.
+     * @return array the updated array of header strings.
+     */
+    public static function cookie_response_headers_add_attributes(array $headerstrings, array $cookiestomatch, array $attributes,
+            bool $casesensitive = false): array {
+
+        return array_map(function($headerstring) use ($attributes, $casesensitive, $cookiestomatch) {
+            if (!self::cookie_response_header_matches_names($headerstring, $cookiestomatch)) {
+                return $headerstring;
+            }
+            foreach ($attributes as $attribute) {
+                if (!self::cookie_response_header_contains_attribute($headerstring, $attribute, $casesensitive)) {
+                    $headerstring = self::cookie_response_header_append_attribute($headerstring, $attribute);
+                }
+            }
+            return $headerstring;
+        }, $headerstrings);
+    }
+
+    /**
+     * Check whether cookies can be used with the current user agent and, if so, via what method they are set.
+     *
+     * Currently, this tries 2 modes of setting a test cookie:
+     * 1. Setting a SameSite=None, Secure cookie. This will work in any first party context, and in 3rd party contexts for
+     * any browsers supporting automatic partitioning of 3rd party cookies (E.g. Firefox, Brave).
+     * 2. If 1 fails, setting a cookie with the Chrome 'Partitioned' attribute included, opting that cookie into CHIPS. This will
+     * work for Chrome.
+     *
+     * Upon completion of the cookie check, the check sets a SESSION flag indicating the method used to set the cookie, and upgrades
+     * the session cookie ('MoodleSession') using the respective method. This ensure the session cookie will continue to be sent.
+     *
+     * Then, the following methods can be used by client code to query whether the UA supports cookies, and how:
+     * @see self::cookies_supported() - whether it could be set at all.
+     * @see self::get_cookies_supported_mode() - if a cookie could be set, what mode was used to set it.
+     *
+     * This permits client code to make sure it's setting its cookies appropriately (via the advertised method), and allows it to
+     * present notices - such as in the case where a given UA is found to be lacking the requisite cookie support.
+     * E.g.
+     * cookie_helper::do_cookie_check($mypageurl);
+     * if (!cookie_helper::cookies_supported()) {
+     *     // Print a notice stating that cookie support is required.
+     * }
+     * // Elsewhere in other client code...
+     * if (cookie_helper::get_cookies_supported_mode() === cookie_helper::COOKIE_METHOD_EXPLICIT_PARTITIONING) {
+     *     // Set a cookie, making sure to use the helper to also opt-in to partitioning.
+     *     setcookie('myauthcookie', 'myauthcookievalue', ['samesite' => 'None', 'secure' => true]);
+     *     cookie_helper::add_partitioning_to_cookie('myauthcookie');
+     * }
+     *
+     * @param \moodle_url $pageurl the URL of the page making the check, used to redirect back to after setting test cookies.
+     * @return void
+     */
+    public static function do_cookie_check(\moodle_url $pageurl): void {
+        global $_COOKIE, $SESSION, $CFG;
+        $cookiecheck1 = optional_param('cookiecheck1', null, PARAM_INT);
+        $cookiecheck2 = optional_param('cookiecheck2', null, PARAM_INT);
+
+        if (empty($cookiecheck1)) {
+            // Start the cookie check. Set two test cookies - one samesite none, and one partitioned - and redirect.
+            // Set cookiecheck to show the check has started.
+            self::set_test_cookie('cookiecheck1', self::COOKIE_METHOD_NO_PARTITIONING);
+            self::set_test_cookie('cookiecheck2', self::COOKIE_METHOD_EXPLICIT_PARTITIONING, true);
+            $pageurl->params([
+                'cookiecheck1' => self::COOKIE_METHOD_NO_PARTITIONING,
+                'cookiecheck2' => self::COOKIE_METHOD_EXPLICIT_PARTITIONING,
+            ]);
+
+            // LTI needs to guarantee the 'SameSite=None', 'Secure' (and sometimes 'Partitioned') attributes are set on the
+            // MoodleSession cookie. This is done via manipulation of the outgoing headers after the cookie check redirect. To
+            // guarantee these outgoing Set-Cookie headers will be created after the redirect, expire the current cookie.
+            self::expire_moodlesession();
+
+            redirect($pageurl);
+        } else {
+            // Have already started a cookie check, so check the result.
+            $cookie1received = isset($_COOKIE['cookiecheck1']) && $_COOKIE['cookiecheck1'] == $cookiecheck1;
+            $cookie2received = isset($_COOKIE['cookiecheck2']) && $_COOKIE['cookiecheck2'] == $cookiecheck2;
+
+            if ($cookie1received || $cookie2received) {
+                // The test cookie could be set and received.
+                // Set a session flag storing the method used to set it, and make sure the session cookie uses this method.
+                $cookiemethod = $cookie1received ? self::COOKIE_METHOD_NO_PARTITIONING : self::COOKIE_METHOD_EXPLICIT_PARTITIONING;
+                $SESSION->auth_lti_cookie_method = $cookiemethod;
+                if ($cookiemethod === self::COOKIE_METHOD_EXPLICIT_PARTITIONING) {
+                    // This assumes secure is set, since that's the only way a paritioned test cookie have been set.
+                    self::add_attributes_to_cookie_response_header('MoodleSession'.$CFG->sessioncookie, ['Partitioned', 'Secure']);
+                }
+            }
+        }
+    }
+
+    /**
+     * If a cookie check has been made, returns whether cookies could be set or not.
+     *
+     * @return bool whether cookies are supported or not.
+     */
+    public static function cookies_supported(): bool {
+        return self::get_cookies_supported_method() !== self::COOKIE_METHOD_NOT_SUPPORTED;
+    }
+
+    /**
+     * If a cookie check has been made, gets the method used to set a cookie, or self::COOKIE_METHOD_NOT_SUPPORTED if not supported.
+     *
+     * For cookie methods:
+     * @see self::COOKIE_METHOD_NOT_SUPPORTED
+     * @see self::COOKIE_METHOD_NO_PARTITIONING
+     * @see self::COOKIE_METHOD_EXPLICIT_PARTITIONING
+     *
+     * @return int the constant representing the method by which the cookie was set, or not.
+     */
+    public static function get_cookies_supported_method(): int {
+        global $SESSION;
+        return $SESSION->auth_lti_cookie_method ?? self::COOKIE_METHOD_NOT_SUPPORTED;
+    }
+
+    /**
+     * Forces the expiry of the MoodleSession cookie.
+     *
+     * This is useful to force a new Set-Cookie header on the next redirect.
+     *
+     * @return void
+     */
+    private static function expire_moodlesession(): void {
+        global $CFG;
+
+        $setcookieheader = array_filter(headers_list(), function($val) use ($CFG) {
+            return self::cookie_response_header_matches_name($val, 'MoodleSession'.$CFG->sessioncookie);
+        });
+        if (!empty($setcookieheader)) {
+            $expirestr = 'Expires='.gmdate(DATE_RFC7231, time() - 60);
+            self::add_attributes_to_cookie_response_header('MoodleSession'.$CFG->sessioncookie, [$expirestr]);
+        } else {
+            setcookie('MoodleSession'.$CFG->sessioncookie, '', time() - 60);
+        }
+    }
+
+    /**
+     * Set a test cookie, using SameSite=None; Secure; attributes if possible, and with or without partitioning opt-in.
+     *
+     * @param string $name cookie name
+     * @param string $value cookie value
+     * @param bool $partitioned whether to try to add partitioning opt-in, which requires secure cookies (https sites).
+     * @return void
+     */
+    private static function set_test_cookie(string $name, string $value, bool $partitioned = false): void {
+        global $CFG;
+        require_once($CFG->libdir . '/sessionlib.php');
+
+        $atts = ['expires' => time() + 30];
+        if (is_moodle_cookie_secure()) {
+            $atts['samesite'] = 'none';
+            $atts['secure'] = true;
+        }
+        setcookie($name, $value, $atts);
+
+        if (is_moodle_cookie_secure() && $partitioned) {
+            self::add_attributes_to_cookie_response_header($name, ['Partitioned']);
+        }
+    }
+
+    /**
+     * Check whether the header string is a 'Set-Cookie' header for the cookie identified by $cookiename.
+     *
+     * @param string $headerstring the header string to check.
+     * @param string $cookiename the name of the cookie to match.
+     * @return bool true if the header string is a Set-Cookie header for the named cookie, false otherwise.
+     */
+    private static function cookie_response_header_matches_name(string $headerstring, string $cookiename): bool {
+        // Generally match the format, but in a case-insensitive way so that 'set-cookie' and "SET-COOKIE" are both valid.
+        return preg_match("/Set-Cookie: *$cookiename=/i", $headerstring)
+            // Case-sensitive match on cookiename, which is case-sensitive.
+            && preg_match("/: *$cookiename=/", $headerstring);
+    }
+
+    /**
+     * Check whether the header string is a 'Set-Cookie' header for the cookies identified in the $cookienames array.
+     *
+     * @param string $headerstring the header string to check.
+     * @param array $cookienames the array of cookie names to match.
+     * @return bool true if the header string is a Set-Cookie header for one of the named cookies, false otherwise.
+     */
+    private static function cookie_response_header_matches_names(string $headerstring, array $cookienames): bool {
+        foreach ($cookienames as $cookiename) {
+            if (self::cookie_response_header_matches_name($headerstring, $cookiename)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Check whether the header string contains the given attribute.
+     *
+     * @param string $headerstring the header string to check.
+     * @param string $attribute the attribute to check for.
+     * @param bool $casesensitive whether to perform a case-sensitive check.
+     * @return bool true if the header contains the attribute, false otherwise.
+     */
+    private static function cookie_response_header_contains_attribute(string $headerstring, string $attribute,
+            bool $casesensitive): bool {
+
+        if ($casesensitive) {
+            return str_contains($headerstring, $attribute);
+        }
+        return str_contains(strtolower($headerstring), strtolower($attribute));
+    }
+
+    /**
+     * Append the given attribute to the header string.
+     *
+     * @param string $headerstring the header string to append to.
+     * @param string $attribute the attribute to append.
+     * @return string the updated header string.
+     */
+    private static function cookie_response_header_append_attribute(string $headerstring, string $attribute): string {
+        $headerstring = rtrim($headerstring, ';'); // Sometimes included.
+        return "$headerstring; $attribute;";
+    }
+}
diff --git a/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php b/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php
new file mode 100644
index 00000000000..b7d79d79b39
--- /dev/null
+++ b/auth/lti/tests/local/ltiadvantage/utility/cookie_helper_test.php
@@ -0,0 +1,215 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace auth_lti\local\ltiadvantage\utility;
+
+/**
+ * Tests for the cookie_helper utility class.
+ *
+ * @package    auth_lti
+ * @copyright  2024 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \auth_lti\local\ltiadvantage\utility\cookie_helper
+ */
+class cookie_helper_test extends \advanced_testcase {
+
+    /**
+     * Testing cookie_response_headers_add_attributes().
+     *
+     * @dataProvider cookie_response_headers_provider
+     *
+     * @param array $headers the headers to search
+     * @param array $cookienames the cookienames to match
+     * @param array $attributes the attributes to add
+     * @param bool $casesensitive whether to do a case-sensitive lookup for the attribute
+     * @param array $expectedheaders the expected, updated headers
+     * @return void
+     */
+    public function test_cookie_response_headers_add_attributes(array $headers, array $cookienames, array $attributes,
+            bool $casesensitive, array $expectedheaders): void {
+
+        $updated = cookie_helper::cookie_response_headers_add_attributes($headers, $cookienames, $attributes, $casesensitive);
+        $this->assertEquals($expectedheaders, $updated);
+    }
+
+    /**
+     * Data provider for testing cookie_response_headers_add_attributes().
+     *
+     * @return array the inputs and expected outputs.
+     */
+    public static function cookie_response_headers_provider(): array {
+        return [
+            'Only one matching cookie header, without any of the attributes' => [
+                'headers' => [
+                    'Set-Cookie: testcookie=value; path=/test/; HttpOnly;',
+                ],
+                'cookienames' => [
+                    'testcookie',
+                ],
+                'attributes' => [
+                    'Partitioned',
+                    'SameSite=None',
+                    'Secure',
+                ],
+                'casesensitive' => false,
+                'output' => [
+                    'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;',
+                ],
+            ],
+            'Several matching cookie headers, without attributes' => [
+                'headers' => [
+                    'Set-Cookie: testcookie=value; path=/test/; HttpOnly;',
+                    'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly;',
+                ],
+                'cookienames' => [
+                    'testcookie',
+                    'mytestcookie',
+                ],
+                'attributes' => [
+                    'Partitioned',
+                    'SameSite=None',
+                    'Secure',
+                ],
+                'casesensitive' => false,
+                'output' => [
+                    'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;',
+                    'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;',
+                ],
+            ],
+            'Several matching cookie headers, several non-matching, all missing all attributes' => [
+                'headers' => [
+                    'Set-Cookie: testcookie=value; path=/test/; HttpOnly;',
+                    'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly;',
+                    'Set-Cookie: anothertestcookie=value; path=/test/; HttpOnly;',
+                ],
+                'cookienames' => [
+                    'testcookie',
+                    'mytestcookie',
+                    'blah',
+                    'etc',
+                ],
+                'attributes' => [
+                    'Partitioned',
+                    'SameSite=None',
+                    'Secure',
+                ],
+                'casesensitive' => false,
+                'output' => [
+                    'Set-Cookie: testcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;',
+                    'Set-Cookie: mytestcookie=value; path=/test/; HttpOnly; Partitioned; SameSite=None; Secure;',
+                    'Set-Cookie: anothertestcookie=value; path=/test/; HttpOnly;',
+                ],
+            ],
+            'Matching cookie headers, some with existing attributes' => [
+                'headers' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; Partitioned; SameSite=None',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None',
+                ],
+                'cookienames' => [
+                    'testcookie',
+                    'mytestcookie',
+                    'etc',
+                ],
+                'attributes' => [
+                    'Partitioned',
+                    'SameSite=None',
+                    'Secure',
+                ],
+                'casesensitive' => false,
+                'output' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; Partitioned; SameSite=None',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned;',
+                ],
+            ],
+            'Matching headers, some with existing attributes, case sensitive' => [
+                'headers' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None',
+                ],
+                'cookienames' => [
+                    'testcookie',
+                    'mytestcookie',
+                    'etc',
+                ],
+                'attributes' => [
+                    'Partitioned',
+                    'SameSite=None',
+                    'Secure',
+                ],
+                'casesensitive' => true,
+                'output' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned; Partitioned; Secure;',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned; Secure;',
+                ],
+            ],
+            'Empty list of cookie names to match, so unmodified inputs' => [
+                'headers' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None',
+                ],
+                'cookienames' => [],
+                'attributes' => [
+                    'Partitioned',
+                    'SameSite=None',
+                    'Secure',
+                ],
+                'casesensitive' => false,
+                'output' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None',
+                ],
+            ],
+            'Empty list of attributes to set, so unmodified inputs' => [
+                'headers' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None',
+                ],
+                'cookienames' => [
+                    'testcookie',
+                    'mycookie',
+                ],
+                'attributes' => [],
+                'casesensitive' => false,
+                'output' => [
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; partitioned',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None',
+                ],
+            ],
+            'Other HTTP headers, some matching Set-Cookie, some not' => [
+                'headers' => [
+                    'Authorization: blah',
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None',
+                ],
+                'cookienames' => [
+                    'testcookie',
+                    'mytestcookie',
+                ],
+                'attributes' => [
+                    'Partitioned',
+                    'SameSite=None',
+                    'Secure',
+                ],
+                'casesensitive' => false,
+                'output' => [
+                    'Authorization: blah',
+                    'Set-Cookie: testcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned',
+                    'Set-Cookie: mytestcookie=value; path=/test/; secure; HttpOnly; SameSite=None; Partitioned;',
+                ],
+            ],
+        ];
+    }
+}

From e2362b0a7a4fd315a37bd1a28822ba932255fb94 Mon Sep 17 00:00:00 2001
From: Jake Dallimore <jake@moodle.com>
Date: Tue, 30 Jan 2024 12:14:45 +0800
Subject: [PATCH 2/6] MDL-80835 enrol_lti: add partitioning support to
 MoodleSession cookie

Adds the property that is required by Chrome to opt-in to its 3rd party
cookie partitioning solution, CHIPS. This specific change deals with the
cookie that is set when the user is not yet auth'd with the site and is
necessary to facilitate OIDC nonce retrieval and validation.
---
 enrol/lti/login.php | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/enrol/lti/login.php b/enrol/lti/login.php
index cb9b4b4adf8..7dbcf1ca77e 100644
--- a/enrol/lti/login.php
+++ b/enrol/lti/login.php
@@ -26,6 +26,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use auth_lti\local\ltiadvantage\utility\cookie_helper;
 use enrol_lti\local\ltiadvantage\lib\issuer_database;
 use enrol_lti\local\ltiadvantage\lib\launch_cache_session;
 use enrol_lti\local\ltiadvantage\repository\application_registration_repository;
@@ -76,6 +77,19 @@ if (empty($_REQUEST['client_id']) && !empty($_REQUEST['id'])) {
     $_REQUEST['client_id'] = $_REQUEST['id'];
 }
 
+// Before beginning the OIDC authentication, ensure the MoodleSession cookie can be used. Browser-specific steps may need to be
+// taken to set cookies in 3rd party contexts. Skip the check if the user is already auth'd. This means that either cookies aren't
+// an issue in the current browser/launch context.
+if (!isloggedin()) {
+    cookie_helper::do_cookie_check(new moodle_url('/enrol/lti/login.php', [
+        'iss' => $iss,
+        'login_hint' => $loginhint,
+        'target_link_uri' => $targetlinkuri,
+        'lti_message_hint' => $ltimessagehint,
+        'client_id' => $_REQUEST['client_id'],
+    ]));
+}
+
 // Now, do the OIDC login.
 LtiOidcLogin::new(
     new issuer_database(new application_registration_repository(), new deployment_repository()),

From c11b1c6b3f5763aaa23cbfd3d75fc7067b5f0cf1 Mon Sep 17 00:00:00 2001
From: Jake Dallimore <jake@moodle.com>
Date: Wed, 14 Feb 2024 11:50:13 +0800
Subject: [PATCH 3/6] MDL-80835 enrol_lti: add cookies required notice to auth
 login endpoint

This will be displayed if the cookie checks fail, which currently occurs
in Safari only.
---
 enrol/lti/classes/output/renderer.php         | 15 ++++++
 enrol/lti/lang/en/enrol_lti.php               |  4 ++
 enrol/lti/login.php                           | 11 ++++
 .../cookies_required_notice.mustache          | 50 +++++++++++++++++++
 4 files changed, 80 insertions(+)
 create mode 100644 enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache

diff --git a/enrol/lti/classes/output/renderer.php b/enrol/lti/classes/output/renderer.php
index 7c17e6e82af..57e4427ce75 100644
--- a/enrol/lti/classes/output/renderer.php
+++ b/enrol/lti/classes/output/renderer.php
@@ -263,4 +263,19 @@ class renderer extends plugin_renderer_base {
         return parent::render_from_template('enrol_lti/local/ltiadvantage/registration_view',
             $tcontext);
     }
+
+    /**
+     * Render a warning, indicating to the user that cookies are require but couldn't be set.
+     *
+     * @return string the html.
+     */
+    public function render_cookies_required_notice(): string {
+        $notification = new notification(get_string('cookiesarerequiredinfo', 'enrol_lti'), notification::NOTIFY_WARNING, false);
+        $tcontext = [
+            'heading' => get_string('cookiesarerequired', 'enrol_lti'),
+            'notification' => $notification->export_for_template($this),
+        ];
+
+        return parent::render_from_template('enrol_lti/local/ltiadvantage/cookies_required_notice', $tcontext);
+    }
 }
diff --git a/enrol/lti/lang/en/enrol_lti.php b/enrol/lti/lang/en/enrol_lti.php
index dce5d50a21a..64d282374c4 100644
--- a/enrol/lti/lang/en/enrol_lti.php
+++ b/enrol/lti/lang/en/enrol_lti.php
@@ -31,6 +31,10 @@ $string['addtocourse'] = 'Add to course';
 $string['addtogradebook'] = 'Add to gradebook';
 $string['allowframeembedding'] = 'Note: It is recommended that the site administration setting \'Allow frame embedding\' is enabled, so that tools are displayed within a frame rather than in a new window.';
 $string['authltimustbeenabled'] = 'Note: This plugin requires the LTI authentication plugin to be enabled too.';
+$string['cookiesarerequired'] = 'Cookies are blocked by your browser';
+$string['cookiesarerequiredinfo'] = 'This tool can\'t be launched because your browser seems to be blocking third-party cookies.
+<br><br>
+To use this tool, try changing your browser cookie settings or using a different browser.';
 $string['copiedtoclipboard'] = '{$a} copied to clipboard';
 $string['copytoclipboard'] = 'Copy to clipboard';
 $string['couldnotestablishproxy'] = 'Could not establish proxy with consumer.';
diff --git a/enrol/lti/login.php b/enrol/lti/login.php
index 7dbcf1ca77e..33b168bd436 100644
--- a/enrol/lti/login.php
+++ b/enrol/lti/login.php
@@ -88,6 +88,17 @@ if (!isloggedin()) {
         'lti_message_hint' => $ltimessagehint,
         'client_id' => $_REQUEST['client_id'],
     ]));
+    if (!cookie_helper::cookies_supported()) {
+        global $OUTPUT, $PAGE;
+        $PAGE->set_context(context_system::instance());
+        $PAGE->set_url(new moodle_url('/enrol/lti/login.php'));
+        $PAGE->set_pagelayout('popup');
+        echo $OUTPUT->header();
+        $renderer = $PAGE->get_renderer('enrol_lti');
+        echo $renderer->render_cookies_required_notice();
+        echo $OUTPUT->footer();
+        die();
+    }
 }
 
 // Now, do the OIDC login.
diff --git a/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache b/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache
new file mode 100644
index 00000000000..2b14840d4af
--- /dev/null
+++ b/enrol/lti/templates/local/ltiadvantage/cookies_required_notice.mustache
@@ -0,0 +1,50 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template enrol_lti/local/ltiadvantage/cookies_required_notice
+
+    Displays a notice, reporting that cookies are required but couldn't be set.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * heading
+    * notification
+
+    Example context (json):
+    {
+        "heading": "Cookies are required",
+        "notification": {
+            "message": "You appear to be using an unsupported browser...",
+            "extraclasses": "",
+            "announce": true,
+            "closebutton": false,
+            "issuccess": false,
+            "isinfo": false,
+            "iswarning": true,
+            "iserror": false
+        }
+    }
+}}
+<h3>{{heading}}</h3>
+{{#notification}}
+    {{> core/notification}}
+{{/notification}}

From dee41e06480e6885665a4f058d9d71b56a71ad14 Mon Sep 17 00:00:00 2001
From: Jake Dallimore <jake@moodle.com>
Date: Tue, 30 Jan 2024 12:25:05 +0800
Subject: [PATCH 4/6] MDL-80835 auth_lti: add partitioning to post-auth
 MoodleSession cookie

Adds the property that is required by Chrome to opt-in to its 3rd party
cookie partitioning solution, CHIPS. This specific change to auth_lti is
to ensure the MoodleSession Set-Cookie header resulting from
complete_user_login() calls (in auth.php) have this property set.
---
 .../ltiadvantage/event/event_handler.php      | 50 +++++++++++++++++++
 auth/lti/db/events.php                        | 33 ++++++++++++
 auth/lti/version.php                          |  2 +-
 3 files changed, 84 insertions(+), 1 deletion(-)
 create mode 100644 auth/lti/classes/local/ltiadvantage/event/event_handler.php
 create mode 100644 auth/lti/db/events.php

diff --git a/auth/lti/classes/local/ltiadvantage/event/event_handler.php b/auth/lti/classes/local/ltiadvantage/event/event_handler.php
new file mode 100644
index 00000000000..a3817f7e46e
--- /dev/null
+++ b/auth/lti/classes/local/ltiadvantage/event/event_handler.php
@@ -0,0 +1,50 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace auth_lti\local\ltiadvantage\event;
+
+use auth_lti\local\ltiadvantage\utility\cookie_helper;
+use core\event\user_loggedin;
+
+/**
+ * Event handler for auth_lti.
+ *
+ * @package    auth_lti
+ * @copyright  2024 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class event_handler {
+
+    /**
+     * Allows the plugin to augment Set-Cookie headers when the user_loggedin event is fired as part of complete_user_login() calls.
+     *
+     * @param user_loggedin $event the event
+     * @return void
+     */
+    public static function handle_user_loggedin(user_loggedin $event): void {
+        // The event data isn't important here. The intent of this listener is to ensure that the MoodleSession cookie gets the
+        // 'Partitioned' attribute, when required - an opt-in flag needed to use Chrome's partitioning mechanism, CHIPS. During LTI
+        // auth, the auth class (auth/lti/auth.php) calls complete_user_login(), which generates a new session cookie as part of its
+        // login process. This handler makes sure that this new cookie is intercepted and partitioned, if needed.
+        if (cookie_helper::cookies_supported()) {
+            if (cookie_helper::get_cookies_supported_method() == cookie_helper::COOKIE_METHOD_EXPLICIT_PARTITIONING) {
+                global $CFG;
+                cookie_helper::add_attributes_to_cookie_response_header('MoodleSession' . $CFG->sessioncookie,
+                    ['Partitioned', 'Secure']);
+            }
+        }
+    }
+}
diff --git a/auth/lti/db/events.php b/auth/lti/db/events.php
new file mode 100644
index 00000000000..19b4df8e344
--- /dev/null
+++ b/auth/lti/db/events.php
@@ -0,0 +1,33 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * LTI Auth plugin event handler definition.
+ *
+ * @package auth_lti
+ * @category event
+ * @copyright 2024 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$observers = [
+    [
+        'eventname' => '\core\event\user_loggedin',
+        'callback' => '\auth_lti\local\ltiadvantage\event\event_handler::handle_user_loggedin',
+    ],
+];
diff --git a/auth/lti/version.php b/auth/lti/version.php
index 0c6c252c6d8..195659ca5da 100644
--- a/auth/lti/version.php
+++ b/auth/lti/version.php
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version = 2022112800; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2022112801; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires = 2022111800; // Requires this Moodle version.
 $plugin->component = 'auth_lti'; // Full name of the plugin (used for diagnostics).

From 0f3d3b2d779b9e99e7d445c86e6dfe7a37842676 Mon Sep 17 00:00:00 2001
From: Jake Dallimore <jake@moodle.com>
Date: Thu, 15 Feb 2024 12:17:29 +0800
Subject: [PATCH 5/6] MDL-80835 enrol_lti: add partitioning support for OIDC
 state cookie

Adds the property that is required by Chrome to opt-in to its 3rd party
cookie partitioning solution, CHIPS. This specific change ensures the
'state' cookie, used in the OIDC handshake, has partitioning support.
This cookie can be partitioned unconditionally, since it's a cookie
controlled by the library and one we don't expect to be set without
partitioning elsewhere.
---
 lib/lti1p3/readme_moodle.txt            | 1 +
 lib/lti1p3/src/ImsStorage/ImsCookie.php | 4 ++++
 2 files changed, 5 insertions(+)

diff --git a/lib/lti1p3/readme_moodle.txt b/lib/lti1p3/readme_moodle.txt
index ca3ac74bc39..c2232aaa79c 100644
--- a/lib/lti1p3/readme_moodle.txt
+++ b/lib/lti1p3/readme_moodle.txt
@@ -5,6 +5,7 @@ This library is a patched for use in Moodle - it requires the following changes
 2. Removal of Guzzle dependency (replaced with generic http client interfaces which are more compatible with Moodle's curl.)
 3. Small fix to http_build_query() usages, to make sure the arg separator is explicitly set to '&', so as not to trip up
 on Moodle's definition of PHP's arg_separator.output which is set to '&amp;' in lib/setup.php.
+4. The Packback\Lti1p3\ImsStorage\ImsCookie::setCookie() method has been locally patched to opt-in to Chrome cookie partitioning.
 
 To upgrade to a new version of this library:
 1. Clone the latest version of the upstream library from github:
diff --git a/lib/lti1p3/src/ImsStorage/ImsCookie.php b/lib/lti1p3/src/ImsStorage/ImsCookie.php
index a98175e2d79..6c6b4c479e7 100644
--- a/lib/lti1p3/src/ImsStorage/ImsCookie.php
+++ b/lib/lti1p3/src/ImsStorage/ImsCookie.php
@@ -2,6 +2,7 @@
 
 namespace Packback\Lti1p3\ImsStorage;
 
+use auth_lti\local\ltiadvantage\utility\cookie_helper;
 use Packback\Lti1p3\Interfaces\ICookie;
 
 class ImsCookie implements ICookie
@@ -33,6 +34,9 @@ class ImsCookie implements ICookie
 
         setcookie($name, $value, array_merge($cookie_options, $same_site_options, $options));
 
+        // Necessary, since partitioned can't be set via setcookie yet.
+        cookie_helper::add_attributes_to_cookie_response_header($name, ['Partitioned']);
+
         // Set a second fallback cookie in the event that "SameSite" is not supported
         setcookie('LEGACY_'.$name, $value, array_merge($cookie_options, $options));
     }

From ea49be1e61a18a77f79c3e9832c6cce526c39df0 Mon Sep 17 00:00:00 2001
From: Jake Dallimore <jake@moodle.com>
Date: Thu, 15 Feb 2024 12:17:47 +0800
Subject: [PATCH 6/6] MDL-80835 auth_lti: fix bad cast breaking samesite LTI
 usage

---
 auth/lti/auth.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/auth/lti/auth.php b/auth/lti/auth.php
index 9bf8975868e..0bee9db28dc 100644
--- a/auth/lti/auth.php
+++ b/auth/lti/auth.php
@@ -116,7 +116,7 @@ class auth_plugin_lti extends \auth_plugin_base {
             if (isloggedin()) {
                 // If a different user is currently logged in, authenticate the linked user instead.
                 global $USER;
-                if ((int) $USER->id !== $user->id) {
+                if ($USER->id !== $user->id) {
                     complete_user_login($user);
                 }
                 // If the linked user is already logged in, skip the call to complete_user_login() because this affects deep linking