Moodle SSO and sessions
There are no WordPress user accounts for members. Every front-end
login on this network authenticates against a Moodle LMS
(“Titus Learning”) over a custom REST plugin, and the resulting session
is held in a moodlelogin cookie shared across the network’s subdomains.
This is the most load-bearing integration on the site: if it breaks, nobody can log in, the Carbon Calculator is inaccessible, and the member-only resource library stops working.
Why it exists
Section titled “Why it exists”The School’s membership, companies and course data live in Moodle, one
Moodle instance per country/school (UK, Australia, France, Scotland/SBCC,
Ireland, USA). WordPress is the marketing and reporting front end. Rather
than mirror accounts into wp_users, the theme treats Moodle as the
identity provider and keeps only a thin local lms_users / cc_users
row per authenticated user so pages can render without a round trip on
every request.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/themes/supplychainschool/inc/routing.php:13 | Rewrite ^log-in/submit → custom_route_action=log-in |
wp-content/themes/supplychainschool/inc/routing.php:59 | POST handler; calls LMSClient::log_in($user, $pass, $redirect_url) |
wp-content/themes/supplychainschool/inc/routing.php:21 | Rewrite ^log-out → LMSClient::perform_cookie_logout() |
wp-content/themes/supplychainschool/template-log-in.php | The “Log In” page template and all ?login_error= messages |
wp-content/themes/supplychainschool/inc/classes/LMSClient.php:1160 | LMSClient::log_in() — the whole login flow |
wp-content/themes/supplychainschool/inc/classes/LMSClient.php:1318 | LMSClient::get_logged_in_user() — reads/validates the cookie |
wp-content/themes/supplychainschool/inc/classes/LMSClient.php:313 | bootstrap_session_from_email() — recovers a session from an email |
How it works
Section titled “How it works”LMSClient.php runs a large block of define()s at include time, before
the class body, keyed off $_SERVER['HTTP_HOST'] and
get_current_blog_id() (LMSClient.php:1-226). This decides:
LMS_URL— which Moodle instance to talk to.CMS_LOG_IN_PATH,LMS_SIGN_UP_URL,LMS_FORGOTTEN_PASS_URL.TOP_LEVEL_DOMAIN— the cookie domain, so a session set onsupplychainschool.co.ukis visible oncarbon.supplychainschool.co.uk.TOPICS_SLUG— localised topic archive slug (sujetson the French site).
Host detection is a literal allow-list of staging/local hostnames
(LMSClient.php:5-14) plus a *.local regex. On a match the site points
at the staging Moodle instances; otherwise production.
Login, in order:
flowchart TD
A[POST /log-in/submit] --> B[LMSClient::log_in]
B --> C[Moodle action=login_get_token]
C -->|fail| C2[redirect CMS_LOG_IN_PATH login_error=userpass]
C -->|token + user_id| D[Moodle action=get_user]
D --> E[store_or_retrieve_user writes lms_users row, returns wp_token]
E --> F[set moodlelogin cookie on TOP_LEVEL_DOMAIN]
F --> G{redirect targets Carbon Calculator}
G -->|yes| H[ensure cc_users rows + CarbonCalc LMS sync_roles_for_email]
H --> I[Carbon access gates may redirect to cc_access_error]
G -->|no| J[output_login_form self-POSTs to Moodle to open Moodle session]
I --> J
Notable specifics:
- The final step is not a redirect.
output_login_form()(LMSClient.php:1038) renders a self-submitting HTML form that POSTs the user id and token to Moodle. The comment atLMSClient.php:1314says this is to avoid firewall issues with the token in a query string. - The local user row is written by
store_or_retrieve_user()(LMSClient.php:742) into thelms_userstable, returning awp_tokenwhich is what goes in the cookie — the Moodle token stays server-side. get_logged_in_user()caches per request in$logged_in_user_cacheand returnsnullfor guests. Callers must handlenull.- Carbon Calculator sessions additionally get
cc_usersrows viaensure_carbon_calculator_user_rows()(LMSClient.php:429), and role sync is triggered when the current blog is the Carbon blog or the redirect URL points at Carbon (redirect_targets_carbon_calculator(),LMSClient.php:1128, which sniffs for the stringsmy-company-emissions,carbon-emissions-report,share-carbon-emissions-report). - Logout clears the cookie on every candidate domain
(
moodle_login_cookie_domains_to_clear(),LMSClient.php:850) then redirects to Moodle’s logout so the Moodle session dies too (moodle_logout_redirect_url(),LMSClient.php:952).
Blog ID → school mapping, as coded:
| Blog | School | Moodle |
|---|---|---|
| 1 | UK | UK_LMS_URL |
| 2 | Australia | AU_LMS_URL |
| 3 | France | FR_LMS_URL (log-in path /se-connecter) |
| 5 | SBCC / Climate Action Hub | SCT_LMS_URL |
| 6 | Carbon Calculator (staging) — comment says “Irish school” | IE_LMS_URL in prod, UK staging locally |
| 7 | USA | US_LMS_URL |
| 8 | Carbon Calculator (production) | UK Moodle |
Blog 6 does double duty in the code — the comment calls it the Irish
school, but carbon_calculator_blog_ids() (LMSClient.php:252) treats 6
as Carbon on staging and 8 as Carbon in production. Treat the comment as
stale; the function is authoritative.
Configuration
Section titled “Configuration”Constants, defined in wp-config.php (gitignored — never in the repo):
| Name | Purpose |
|---|---|
MOODLE_WS_TOKEN | Moodle web-service token for local_scssusers_* calls |
TOP_LEVEL_DOMAIN | Cookie domain for moodlelogin; usually derived, can be pre-defined |
CARBONCALC_SKIP_AUTH | Local only — disables Carbon auth gates |
CARBONCALC_CARBON_BLOG_ID | Pins which blog holds Carbon tables |
CARBONCALC_DEV_BYPASS | Impersonation bypass for the company gate |
CARBONCALC_LMS_DEBUG | Renders the LMS debug panel in the Carbon footer |
LMS_URL and friends are derived in LMSClient.php, not configured. The
Moodle version (3 vs 4) comes from an ACF option moodle_version, forced
to 4 on blog 1 when unset (LMSClient.php:119).
Invariants and gotchas
Section titled “Invariants and gotchas”LMSClient.phpmust be included after the constants and before anything that usesLMS_URL.private static $base_url = LMS_URL;(LMSClient.php:238) is evaluated at class-definition time, so the constants must already exist. Moving therequireinfunctions.php:276will fatal.- Adding a subsite means editing
LMSClient.php. Blog IDs are hard-coded in a chain ofif (get_current_blog_id() == N). A new school gets noLMS_URLand every page fatals on the missing constant. - Adding a staging hostname means editing the allow-list at
LMSClient.php:5. A new staging host that isn’t listed will talk to production Moodle and set production cookie domains. - The
TOP_LEVEL_DOMAINderivation for.buildhosts is duplicated verbatim inside a nestedif(LMSClient.php:26-79); the inner copy is unreachable because the outer copy already defined the constant. Do not “tidy” it by deleting the outer branch. error_log()calls fire on every request at include time (LMSClient.php:91and:94, duplicated) and insidelog_in()/get_logged_in_user(). Usernames are logged; passwords are not. On production this is a lot of log volume.- The login form writes
redirect_urlstraight from$_GETinto a hidden input (template-log-in.php:25) without escaping.
Changing it safely
Section titled “Changing it safely”- New login-adjacent behaviour belongs in
LMSClientas a static method, called fromrouting.php’sswitch. Do not add new top-level rewrite rules elsewhere —routing_requests()is the single dispatcher. - New
?login_error=codes need acasein bothtemplate-log-in.php:31(the message) and, if it’s a Carbon gate,CarbonCalc\LMS::carbon_company_gate_login_error_param()(plugins/carboncalc/includes/LMS.php:414). - Rewrite rules register on
init. After changing them, hit any URL with?flush=1—routing_requests()callsflush_rewrite_rules()when that param is truthy (routing.php:50). - There is no test suite. Verify by hand:
- Log in on blog 1; confirm the
moodlelogincookie domain. - Log in with
redirect_urlpointing at/my-company-emissions/; confirm you land there withcc_auth=1. - Log out; confirm the cookie is gone on both the main and Carbon hosts.
- Check
wp-content/carboncalc-login-denied.logfor gate denials.
- Log in on blog 1; confirm the
- Deliberately not abstracted: the per-blog
define()blocks. They are ugly but they run beforeinitand before ACF is available, earlier than an options-driven config could work. Leave them.
None. No PHPUnit setup, no fixtures, no CI test step —
bitbucket-pipelines.yml only runs git ftp push. phpcs.xml.dist
exists in the theme but is not wired into the pipeline.
Related: [[carbon-access-gates]], [[lms-resource-sync]], [[member-directory-and-stats]].