Skip to content

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.

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.

PathWhat it does
wp-content/themes/supplychainschool/inc/routing.php:13Rewrite ^log-in/submit → custom_route_action=log-in
wp-content/themes/supplychainschool/inc/routing.php:59POST handler; calls LMSClient::log_in($user, $pass, $redirect_url)
wp-content/themes/supplychainschool/inc/routing.php:21Rewrite ^log-out → LMSClient::perform_cookie_logout()
wp-content/themes/supplychainschool/template-log-in.phpThe “Log In” page template and all ?login_error= messages
wp-content/themes/supplychainschool/inc/classes/LMSClient.php:1160LMSClient::log_in() — the whole login flow
wp-content/themes/supplychainschool/inc/classes/LMSClient.php:1318LMSClient::get_logged_in_user() — reads/validates the cookie
wp-content/themes/supplychainschool/inc/classes/LMSClient.php:313bootstrap_session_from_email() — recovers a session from an email

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 on supplychainschool.co.uk is visible on carbon.supplychainschool.co.uk.
  • TOPICS_SLUG — localised topic archive slug (sujets on 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 at LMSClient.php:1314 says 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 the lms_users table, returning a wp_token which is what goes in the cookie — the Moodle token stays server-side.
  • get_logged_in_user() caches per request in $logged_in_user_cache and returns null for guests. Callers must handle null.
  • Carbon Calculator sessions additionally get cc_users rows via ensure_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 strings my-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:

BlogSchoolMoodle
1UKUK_LMS_URL
2AustraliaAU_LMS_URL
3FranceFR_LMS_URL (log-in path /se-connecter)
5SBCC / Climate Action HubSCT_LMS_URL
6Carbon Calculator (staging) — comment says “Irish school”IE_LMS_URL in prod, UK staging locally
7USAUS_LMS_URL
8Carbon 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.

Constants, defined in wp-config.php (gitignored — never in the repo):

NamePurpose
MOODLE_WS_TOKENMoodle web-service token for local_scssusers_* calls
TOP_LEVEL_DOMAINCookie domain for moodlelogin; usually derived, can be pre-defined
CARBONCALC_SKIP_AUTHLocal only — disables Carbon auth gates
CARBONCALC_CARBON_BLOG_IDPins which blog holds Carbon tables
CARBONCALC_DEV_BYPASSImpersonation bypass for the company gate
CARBONCALC_LMS_DEBUGRenders 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).

  • LMSClient.php must be included after the constants and before anything that uses LMS_URL. private static $base_url = LMS_URL; (LMSClient.php:238) is evaluated at class-definition time, so the constants must already exist. Moving the require in functions.php:276 will fatal.
  • Adding a subsite means editing LMSClient.php. Blog IDs are hard-coded in a chain of if (get_current_blog_id() == N). A new school gets no LMS_URL and 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_DOMAIN derivation for .build hosts is duplicated verbatim inside a nested if (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:91 and :94, duplicated) and inside log_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_url straight from $_GET into a hidden input (template-log-in.php:25) without escaping.
  • New login-adjacent behaviour belongs in LMSClient as a static method, called from routing.php’s switch. Do not add new top-level rewrite rules elsewhere — routing_requests() is the single dispatcher.
  • New ?login_error= codes need a case in both template-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() calls flush_rewrite_rules() when that param is truthy (routing.php:50).
  • There is no test suite. Verify by hand:
    1. Log in on blog 1; confirm the moodlelogin cookie domain.
    2. Log in with redirect_url pointing at /my-company-emissions/; confirm you land there with cc_auth=1.
    3. Log out; confirm the cookie is gone on both the main and Carbon hosts.
    4. Check wp-content/carboncalc-login-denied.log for gate denials.
  • Deliberately not abstracted: the per-blog define() blocks. They are ugly but they run before init and 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]].