diff --git a/.secrets.baseline b/.secrets.baseline index b70ef88..cb296a6 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -189,7 +189,7 @@ "filename": "tests/test_auth_login.py", "hashed_secret": "f32b67c7e26342af42efabc674d441dca0a281c5", "is_verified": false, - "line_number": 369, + "line_number": 383, "is_secret": false }, { @@ -197,7 +197,7 @@ "filename": "tests/test_auth_login.py", "hashed_secret": "e6f8cac5c7482fb7e8de06675b6fc585089bf0d7", "is_verified": false, - "line_number": 748, + "line_number": 772, "is_secret": false }, { @@ -205,7 +205,7 @@ "filename": "tests/test_auth_login.py", "hashed_secret": "fdda285858f81043adc365d4533985b6d804c5c2", "is_verified": false, - "line_number": 749, + "line_number": 773, "is_secret": false } ], @@ -299,7 +299,7 @@ "filename": "tests/test_ms_auth.py", "hashed_secret": "e6f8cac5c7482fb7e8de06675b6fc585089bf0d7", "is_verified": false, - "line_number": 619, + "line_number": 623, "is_secret": false }, { @@ -307,7 +307,7 @@ "filename": "tests/test_ms_auth.py", "hashed_secret": "e3eabd3c678fb7562f56965f9f6801ba69f7386d", "is_verified": false, - "line_number": 627, + "line_number": 631, "is_secret": false }, { @@ -315,7 +315,7 @@ "filename": "tests/test_ms_auth.py", "hashed_secret": "fdda285858f81043adc365d4533985b6d804c5c2", "is_verified": false, - "line_number": 628, + "line_number": 632, "is_secret": false }, { @@ -323,7 +323,7 @@ "filename": "tests/test_ms_auth.py", "hashed_secret": "6f190f6be91683cc65cf8f29f3829efd09251eee", "is_verified": false, - "line_number": 746, + "line_number": 750, "is_secret": false } ], @@ -333,7 +333,7 @@ "filename": "tests/test_ms_auth_characterization.py", "hashed_secret": "a936040fc880f71bff121baf4f243117f4207a44", "is_verified": false, - "line_number": 44, + "line_number": 46, "is_secret": false }, { @@ -341,7 +341,7 @@ "filename": "tests/test_ms_auth_characterization.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 220, + "line_number": 261, "is_secret": false }, { @@ -349,7 +349,7 @@ "filename": "tests/test_ms_auth_characterization.py", "hashed_secret": "e6f8cac5c7482fb7e8de06675b6fc585089bf0d7", "is_verified": false, - "line_number": 1012, + "line_number": 1053, "is_secret": false }, { @@ -357,7 +357,7 @@ "filename": "tests/test_ms_auth_characterization.py", "hashed_secret": "3af92b0944046c16bd9fce6bc20e83525ae8f100", "is_verified": false, - "line_number": 1107, + "line_number": 1148, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-15T06:11:07Z" + "generated_at": "2026-10-15T06:40:54Z" } diff --git a/docs/auth-microsoft-sso.md b/docs/auth-microsoft-sso.md index 482aab3..69196f1 100644 --- a/docs/auth-microsoft-sso.md +++ b/docs/auth-microsoft-sso.md @@ -164,7 +164,9 @@ ciphertext. Cleared after D2L cookies are extracted successfully. Recoverable post-EndAuth or KMSI failures retain their checkpoint for another `auth verify` attempt; -other failures discard it so a stale flow cannot block a fresh login. +other failures discard it so a stale flow cannot block a fresh login. An +expired-password page after MFA also discards it: resuming could only reach +the same page. ## Protocol details (why the payloads look this way) @@ -204,6 +206,16 @@ After ProcessAuth, Microsoft may show “Stay signed in” (`CmsiInterrupt`). Th Without `canary` and `hpgrequestid`, Azure returns `AADSTS165000` (missing user-context tokens). +### Expired password (`ConvergedChangePassword`) + +When the password has expired, Microsoft answers ProcessAuth (or the +credentials POST) with an “Update your password” page: `$Config.pgid` +`ConvergedChangePassword`, `stsErrorCode` 50055 (50144 for an on-premises +password), `urlPost` `/common/SSPR/End`, and no form. The walk stops there +and reports the expired password; it never submits that page. A page without +a readable code is reported as 50055. If `$Config` doesn't parse, the page +isn't recognised and the old generic error is shown. + ### SAML ACS POST `SAMLResponse` (and `RelayState` from the HTML form) to D2L’s ACS with diff --git a/lighthouse_cli/auth.py b/lighthouse_cli/auth.py index 2f18c69..28b8d66 100644 --- a/lighthouse_cli/auth.py +++ b/lighthouse_cli/auth.py @@ -47,6 +47,7 @@ MFA_METHOD_CHOOSE, MFA_METHOD_PUSH, MFA_METHOD_SMS, + PASSWORD_EXPIRED_CODES, VALID_MFA_METHODS, MfaPendingError, MicrosoftSSOError, @@ -104,6 +105,7 @@ class _HiddenInputUnavailableError(Exception): r"(?i)--(?:pass(?:word)?|token|secret)\s+[^\s,;{}\[\]]+" ) _AUTH_CODE_RE = re.compile(r"(?:^|:\s*)\[(\d{3,8})\]\s*") +_PASSWORD_EXPIRED_CODES = frozenset(str(code) for code in PASSWORD_EXPIRED_CODES) _AUTH_UNSAFE_BRACKET_RE = re.compile(r"[{}]|\[(?!\d{3,8}\])|(? str | None: def _safe_auth_category(text: str, code: str | None) -> str: """Map an auth error to a fixed, non-upstream diagnostic.""" lowered = text.casefold() + if lowered.startswith("authentication failed") and code in _PASSWORD_EXPIRED_CODES: + return ( + f"Your Microsoft password has expired ({code}). " + "Change it in a browser, then sign in again with the new password." + ) if lowered.startswith("authentication failed"): return f"Authentication failed ({code})." if code else _AUTH_ERROR_FALLBACK if lowered.startswith("login completed but session verification failed"): diff --git a/lighthouse_cli/ms_auth.py b/lighthouse_cli/ms_auth.py index 1002562..e9fd9e5 100644 --- a/lighthouse_cli/ms_auth.py +++ b/lighthouse_cli/ms_auth.py @@ -64,6 +64,7 @@ MFA_METHOD_AUTO, MFA_METHOD_INSTRUCTIONS, MS_ERROR_CODES, + PASSWORD_EXPIRED_CODES, SERVER_SENT_CODE_AUTH_IDS, VALID_MFA_METHODS, MfaPendingError, @@ -250,16 +251,18 @@ def from_response(cls, resp: requests.Response) -> ResponseSnapshot: class Transition(NamedTuple): """A pure routing decision for the interstitial walk. - ``kind`` is one of: ``saml`` (done), ``mfa`` (terminal — an MFA - verification page the caller must handle), ``sso_reload`` (re-POST the - echoed credential params), ``redirect``, ``hiddenform``, ``kmsi``, - ``samlrequest``, ``stop``. + ``kind`` is one of: ``saml`` (done), ``password_change`` (terminal — the + password has expired; ``error_code`` holds the AADSTS code), ``mfa`` + (terminal — an MFA verification page the caller must handle), + ``sso_reload`` (re-POST the echoed credential params), ``redirect``, + ``hiddenform``, ``kmsi``, ``samlrequest``, ``stop``. """ kind: str url: str = "" data: dict[str, str] | None = None saml_response: str | None = None + error_code: int | None = None # --------------------------------------------------------------------------- @@ -340,6 +343,25 @@ def is_error_page(snapshot: ResponseSnapshot) -> bool: ) + +def password_change_code(snapshot: ResponseSnapshot) -> int | None: + """The AADSTS code of a "change your password" page, else None. + + Keyed on the page id, so an ``stsErrorCode`` on any other page is never + misread. The page itself means the password has expired: an absent or + unexpected code reports 50055. + """ + cfg = _extract_config_json(snapshot.html) or {} + if cfg.get("pgid") != "ConvergedChangePassword": + return None + raw = cfg.get("stsErrorCode") + code = None + if isinstance(raw, (str, int)) and not isinstance(raw, bool): + with suppress(ValueError): + code = int(raw) + return code if code in PASSWORD_EXPIRED_CODES else 50055 + + def describe_page_shape(snapshot: ResponseSnapshot) -> str: """One-line, secret-free summary of an unrecognized Microsoft page. @@ -417,7 +439,7 @@ def build_sso_error(code: int | None, msg: str | None, step: str) -> MicrosoftSS recovery = "This email is not associated with a Microsoft account in this tenant." elif code in (50056, 50133): recovery = "Password is incorrect. If you recently changed your password, try again." - elif code == 50055: + elif code in PASSWORD_EXPIRED_CODES: recovery = "Your password has expired. Reset it via the Microsoft portal." elif code == 50057: recovery = "Your account has been disabled. Contact IT support." @@ -749,8 +771,10 @@ def classify_post_mfa(snapshot: ResponseSnapshot, base_url: str) -> Transition: """Pure router for one step of the interstitial walk. Order matters and mirrors the wire protocol: an extracted SAMLResponse - ends the walk; an MFA verification page is terminal for the caller to - handle; the sso_reload session-pull hop re-POSTs echoed credentials; + ends the walk; an expired-password page is terminal and checked before + the looser MFA heuristics; an MFA verification page is terminal for the + caller to handle; the sso_reload session-pull hop re-POSTs echoed + credentials; redirects are followed; then auto-submit forms (hiddenform, KMSI/CMSI); then the SAMLRequest JS redirect; otherwise stop. """ @@ -758,6 +782,10 @@ def classify_post_mfa(snapshot: ResponseSnapshot, base_url: str) -> Transition: if saml: return Transition(kind="saml", saml_response=saml) + expired_code = password_change_code(snapshot) + if expired_code is not None: + return Transition(kind="password_change", error_code=expired_code) + if is_mfa_page(snapshot.html): return Transition(kind="mfa") @@ -2160,7 +2188,7 @@ def _mfa_finish_after_begin( kmsi_checkpoint={"url": snap.url, "html": snap.html}, ) - if is_mfa_page(snap.html): + if is_mfa_page(snap.html) and password_change_code(snap) is None: raise MicrosoftSSOError( "2FA verification failed: invalid or expired code.", step="MFA", @@ -2398,7 +2426,7 @@ def _step_handle_mfa_legacy_form( mfa_data[key] = str(original_config[key]) snap = ResponseSnapshot.from_response(self._post(mfa_url, data=mfa_data)) - if is_mfa_page(snap.html): + if is_mfa_page(snap.html) and password_change_code(snap) is None: raise MicrosoftSSOError( "2FA verification failed: invalid or expired code.", step="MFA", @@ -2425,12 +2453,20 @@ def _advance_to_saml( (which continues inline through any interrupts before MFA). Terminal outcomes: a page carrying SAMLResponse, an MFA page, or an - unrecognized page returned to the caller. Exhausting either safety - budget raises a clean error. + unrecognized page returned to the caller. An expired-password page + raises without submitting anything; the post-MFA walk also drops the + MFA checkpoint, which could only lead back to the same page. + Exhausting either safety budget raises a clean error. """ sso_reloads = 0 for _ in range(_MAX_POST_MFA_HOPS): transition = classify_post_mfa(snapshot, base_url) + if transition.kind == "password_change": + if checkpoint_kmsi: + from lighthouse_cli.config import clear_mfa_pending + + clear_mfa_pending() + raise build_sso_error(transition.error_code, None, "SSO interstitial walk") if transition.kind == "sso_reload": if sso_reloads >= _MAX_SSO_RELOADS: raise MicrosoftSSOError( diff --git a/lighthouse_cli/ms_errors.py b/lighthouse_cli/ms_errors.py index 7af7bff..668e392 100644 --- a/lighthouse_cli/ms_errors.py +++ b/lighthouse_cli/ms_errors.py @@ -177,6 +177,10 @@ def safe_diagnostic_text(value: object, *, fallback: str) -> str: MFA_AUTH_VOICE_OFFICE: "Answer the office phone call and press # to approve the sign-in.", } +# Expired-password codes: the cloud password (50055) or an on-premises one +# synced to Entra (50144). Both get the same recovery text. +PASSWORD_EXPIRED_CODES = frozenset({50055, 50144}) + # Microsoft error codes and their meanings MS_ERROR_CODES: dict[int, str] = { 50034: "User account does not exist in this tenant. Check your email address.", diff --git a/tests/test_auth_login.py b/tests/test_auth_login.py index 8e78daa..6ddd74c 100644 --- a/tests/test_auth_login.py +++ b/tests/test_auth_login.py @@ -31,7 +31,7 @@ from lighthouse_cli.cli import cli from lighthouse_cli.config import load_cookies, save_cookies, save_mfa_pending from lighthouse_cli.credential_store import CredentialStore, CredentialStoreError -from lighthouse_cli.ms_auth import MicrosoftSSOError +from lighthouse_cli.ms_auth import MicrosoftSSOError, build_sso_error from lighthouse_cli.ms_errors import RELOGIN_CATEGORIES, VALID_MFA_METHODS from lighthouse_cli.ms_mfa import MfaProbeResult, UserProof from lighthouse_cli.session_renewal import RenewalError, RenewalUnavailableError, renewal_lock @@ -79,6 +79,20 @@ def test_first_party_auth_failures_keep_safe_actionable_categories( assert auth_mod._safe_auth_error_message(message) == expected +def _expired_password_message(code: int) -> str: + return ( + f"Your Microsoft password has expired ({code}). " + "Change it in a browser, then sign in again with the new password." + ) + + +@pytest.mark.parametrize("code", [50055, 50144]) +def test_expired_password_error_keeps_fixed_message(code: int) -> None: + """The full rendered error (Step and Fix lines included) maps to a fixed text.""" + rendered = str(build_sso_error(code, None, "SSO interstitial walk")) + assert auth_mod._safe_auth_error_message(rendered) == _expired_password_message(code) + + @pytest.fixture def cli_runner() -> CliRunner: return CliRunner() @@ -725,6 +739,16 @@ def test_json_output_failure(cli_runner: CliRunner, creds_env: Path) -> None: assert "error" in data +def test_json_login_reports_expired_password(cli_runner: CliRunner, creds_env: Path) -> None: + error = build_sso_error(50055, None, "SSO interstitial walk") + with _mock_sso(login_side_effect=error): + result = _invoke_login(cli_runner, ["--totp", "123456", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": _expired_password_message(50055)} + assert result.stderr == f"Error: {_expired_password_message(50055)}\n" + + def test_auth_json_error_has_one_stdout_document_and_stderr_diagnostic( capsys: pytest.CaptureFixture[str], ) -> None: @@ -873,6 +897,28 @@ def test_verify_never_saves_credentials( store_cls.return_value.save.assert_not_called() +def test_json_verify_reports_expired_password( + cli_runner: CliRunner, isolated_config: Path, +) -> None: + store_cls = MagicMock() + store_cls.return_value.preflight.return_value = "passphrase" + + with patch.object(auth_mod, "load_mfa_pending", return_value={"mfa_method": "sms"}): + with patch.object(auth_mod, "MicrosoftSSOClient") as sso_cls: + sso_cls.return_value.complete_mfa_pending.side_effect = build_sso_error( + 50055, None, "SSO interstitial walk" + ) + with patch.object(auth_mod, "CredentialStore", store_cls): + result = cli_runner.invoke( + cli, ["auth", "verify", "123456", "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": _expired_password_message(50055)} + assert result.stderr == f"Error: {_expired_password_message(50055)}\n" + + def test_verify_without_pending_reports_usage_before_key_preflight( cli_runner: CliRunner, isolated_config: Path, ) -> None: diff --git a/tests/test_ms_auth.py b/tests/test_ms_auth.py index 4dfcd76..a5054a4 100644 --- a/tests/test_ms_auth.py +++ b/tests/test_ms_auth.py @@ -602,6 +602,10 @@ def test_rendered_message( for needle in needles: assert needle in rendered + @pytest.mark.parametrize("code", [50055, 50144]) + def test_expired_password_recovery(self, code: int) -> None: + assert "expired" in (build_sso_error(code, None, "SSO interstitial walk").recovery or "") + def test_mfa_required_recovery_does_not_assume_code_channel(self) -> None: err = build_sso_error(50076, None, "POST credentials") recovery = err.recovery or "" diff --git a/tests/test_ms_auth_characterization.py b/tests/test_ms_auth_characterization.py index 0cc1577..8a66c5b 100644 --- a/tests/test_ms_auth_characterization.py +++ b/tests/test_ms_auth_characterization.py @@ -20,7 +20,7 @@ import pytest import requests -from lighthouse_cli.config import COOKIE_NAMES, load_mfa_pending +from lighthouse_cli.config import COOKIE_NAMES, load_mfa_pending, save_mfa_pending from lighthouse_cli.ms_auth import ( _MAX_SSO_RELOADS, MfaPendingError, @@ -30,8 +30,10 @@ classify_post_mfa, describe_page_shape, is_error_page, + is_hiddenform_page, is_mfa_page, is_sso_reload_page, + password_change_code, sso_reload_transition, ) from lighthouse_cli.ms_errors import MFA_METHOD_APP @@ -212,6 +214,45 @@ def sso_reload_html( ) +# Microsoft's "Update your password" page (pgid ConvergedChangePassword, +# AADSTS 50055) as ProcessAuth served it live in Oct 2026: no form, and a +# $Config that targets the SSPR flow and echoes the credentials. Values are +# sentinels; an override of OMIT drops the key. +CHANGE_PASSWORD_FLOW_TOKEN = "CHANGE-PASSWORD-FLOW-TOKEN" # pragma: allowlist secret +OMIT = object() + + +def change_password_html(body: str = "", **overrides: Any) -> str: + cfg: dict[str, Any] = { + "pgid": "ConvergedChangePassword", + "stsErrorCode": "50055", + "urlPost": "/common/SSPR/End", + "sFT": CHANGE_PASSWORD_FLOW_TOKEN, + "sPOST_Username": USERNAME, + "oPostParams": {"login": USERNAME, "passwd": PASSWORD, "flowToken": CHANGE_PASSWORD_FLOW_TOKEN}, + } + for key, value in overrides.items(): + if value is OMIT: + cfg.pop(key, None) + else: + cfg[key] = value + return ( + "