From e8c78c1fb4c6f33aba6188b1961137ab0c125845 Mon Sep 17 00:00:00 2001
From: rabesss <26330469+rabesss@users.noreply.github.com>
Date: Thu, 15 Oct 2026 11:12:22 +0530
Subject: [PATCH 1/2] fix(auth): say when the Microsoft password has expired
When the Microsoft password had expired, ProcessAuth (or the credentials
POST) answered with Microsoft's "Update your password" page (pgid
ConvergedChangePassword, AADSTS 50055). It has no form, so the sign-in
walk stopped there and the CLI reported the generic "Authentication
failed. Check your credentials and try again." even with the right
password and code.
The walk now recognises that page by its page id, before the looser MFA
and session-pull checks, and raises the 50055 (or 50144) error without
submitting anything to it. The two post-MFA "invalid code" checks no
longer mistake it for an MFA page. `auth login` and `auth verify` show
a fixed message, "Your Microsoft password has expired (50055). Change it
in a browser, then sign in again with the new password.", on stderr and
in the --json error. After MFA the saved checkpoint is dropped, since
resuming it could only reach the same page; the MFA-method probe leaves
it alone.
The stsErrorCode is read strictly (string or int, never bool or float),
and 50144 now gets the same expired-password recovery as 50055.
Plan review: gpt-6.1-sol, gpt-6-luna, muse-wonder-full-1.0 (step text
that tripped the secret-field filter, MFA and session-pull precedence,
strict code parsing, 50144, checkpoint clearing, and the verify, KMSI
and legacy-form tests).
---
.secrets.baseline | 24 +--
lighthouse_cli/auth.py | 6 +
lighthouse_cli/ms_auth.py | 59 ++++--
tests/test_auth_login.py | 48 ++++-
tests/test_ms_auth.py | 4 +
tests/test_ms_auth_characterization.py | 241 ++++++++++++++++++++++++-
6 files changed, 357 insertions(+), 25 deletions(-)
diff --git a/.secrets.baseline b/.secrets.baseline
index adab5a0..847d94f 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": 361,
+ "line_number": 375,
"is_secret": false
},
{
@@ -197,7 +197,7 @@
"filename": "tests/test_auth_login.py",
"hashed_secret": "e6f8cac5c7482fb7e8de06675b6fc585089bf0d7",
"is_verified": false,
- "line_number": 740,
+ "line_number": 764,
"is_secret": false
},
{
@@ -205,7 +205,7 @@
"filename": "tests/test_auth_login.py",
"hashed_secret": "fdda285858f81043adc365d4533985b6d804c5c2",
"is_verified": false,
- "line_number": 741,
+ "line_number": 765,
"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": 45,
"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": 258,
"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": 1050,
"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": 1145,
"is_secret": false
}
],
@@ -392,5 +392,5 @@
}
]
},
- "generated_at": "2026-10-09T17:28:09Z"
+ "generated_at": "2026-10-15T05:40:37Z"
}
diff --git a/lighthouse_cli/auth.py b/lighthouse_cli/auth.py
index 8562f9f..a1954b4 100644
--- a/lighthouse_cli/auth.py
+++ b/lighthouse_cli/auth.py
@@ -86,6 +86,7 @@ class _PromptUnavailableError(Exception):
r"(?i)--(?:pass(?:word)?|token|secret)\s+[^\s,;{}\[\]]+"
)
_AUTH_CODE_RE = re.compile(r"(?:^|:\s*)\[(\d{3,8})\]\s*")
+_PASSWORD_EXPIRED_CODES = frozenset({"50055", "50144"})
_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 f19de05..8fed74b 100644
--- a/lighthouse_cli/ms_auth.py
+++ b/lighthouse_cli/ms_auth.py
@@ -249,16 +249,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
# ---------------------------------------------------------------------------
@@ -339,6 +341,27 @@ def is_error_page(snapshot: ResponseSnapshot) -> bool:
)
+_PASSWORD_EXPIRED_CODES = frozenset({50055, 50144})
+
+
+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.
@@ -416,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."
@@ -713,8 +736,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.
"""
@@ -722,6 +747,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")
@@ -2026,7 +2055,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",
@@ -2236,7 +2265,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",
@@ -2263,12 +2292,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/tests/test_auth_login.py b/tests/test_auth_login.py
index 22c8310..7d40c29 100644
--- a/tests/test_auth_login.py
+++ b/tests/test_auth_login.py
@@ -29,7 +29,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 CredentialStoreError
-from lighthouse_cli.ms_auth import MicrosoftSSOError
+from lighthouse_cli.ms_auth import MicrosoftSSOError, build_sso_error
from lighthouse_cli.ms_mfa import MfaProbeResult, UserProof
# ---------------------------------------------------------------------------
@@ -74,6 +74,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()
@@ -717,6 +731,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:
@@ -866,6 +890,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..2efd737 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,
@@ -32,6 +32,7 @@
is_error_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 +213,43 @@ 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 (
+ "
Update your password{body}"
+ )
+
+
+# Also satisfies the loose is_mfa_page heuristics.
+CHANGE_PASSWORD_MFA_LIKE_HTML = change_password_html(
+ body="Enter code
",
+ arrUserProofs=[{"authMethodId": "OneWaySMS", "display": "SMS", "isDefault": True}],
+)
+# Also armed for the session-pull re-POST of the echoed credentials.
+CHANGE_PASSWORD_RELOAD_HTML = change_password_html(urlPost=SSO_RELOAD_URL_POST, iSessionPullType=2)
+
+
# Same-host lookalikes origin pinning must refuse: plain http, a suffix-spoofed
# host, userinfo, and a non-default port.
UNSAFE_MS_URLS = [
@@ -1534,3 +1572,204 @@ def test_signin_error_page_is_not_mfa(self) -> None:
)
assert not is_mfa_page(html)
assert is_error_page(snap(html))
+
+
+# ---------------------------------------------------------------------------
+# Expired password (ConvergedChangePassword)
+# ---------------------------------------------------------------------------
+
+
+def change_password_at(url: str, html: str | None = None) -> FakeResponse:
+ return FakeResponse(200, html=html or change_password_html(), url=url)
+
+
+def assert_expired_password_error(
+ exc: pytest.ExceptionInfo[MicrosoftSSOError], scripted_session: ScriptedSession, code: int = 50055
+) -> None:
+ """The error names the code but echoes nothing from the page, and the
+ page's request was the last one: nothing is submitted to SSPR or re-POSTed."""
+ text = str(exc.value)
+ assert f"[{code}]" in text
+ for secret in (USERNAME, PASSWORD, CHANGE_PASSWORD_FLOW_TOKEN):
+ assert secret not in text
+ assert not any("SSPR" in url for _method, url in scripted_session.calls)
+
+
+class TestExpiredPassword:
+ @pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ pytest.param("50055", 50055, id="string"),
+ pytest.param("50144", 50144, id="string-50144"),
+ pytest.param(50144, 50144, id="int-50144"),
+ # The page alone means the password expired: anything else is 50055.
+ pytest.param(OMIT, 50055, id="missing"),
+ pytest.param(True, 50055, id="bool"),
+ pytest.param("abc", 50055, id="non-numeric"),
+ pytest.param(50144.0, 50055, id="float"),
+ pytest.param([50144], 50055, id="list"),
+ pytest.param("50126", 50055, id="other-code"),
+ ],
+ )
+ def test_password_change_code_parses_strictly(self, raw: object, expected: int) -> None:
+ assert password_change_code(snap(change_password_html(stsErrorCode=raw))) == expected
+
+ @pytest.mark.parametrize(
+ "html",
+ [
+ pytest.param(change_password_html(pgid="ConvergedSignIn"), id="other-pgid"),
+ pytest.param(MYSTERY_HTML, id="no-config"),
+ pytest.param("", id="malformed-config"),
+ ],
+ )
+ def test_password_change_code_needs_the_page_id(self, html: str) -> None:
+ assert password_change_code(snap(html)) is None
+
+ @pytest.mark.parametrize(
+ ("html", "code"),
+ [
+ pytest.param(change_password_html(), 50055, id="plain"),
+ pytest.param(CHANGE_PASSWORD_MFA_LIKE_HTML, 50055, id="beats-mfa-heuristics"),
+ pytest.param(CHANGE_PASSWORD_RELOAD_HTML, 50055, id="beats-sso-reload"),
+ pytest.param(change_password_html(stsErrorCode="50144"), 50144, id="code-50144"),
+ ],
+ )
+ def test_classify_post_mfa_stops_on_expired_password(self, html: str, code: int) -> None:
+ transition = classify_post_mfa(snap(html), MS_BASE)
+ assert transition.kind == "password_change"
+ assert transition.error_code == code
+
+ def test_variants_would_otherwise_route_elsewhere(self) -> None:
+ """Guards the two variants above: each really trips the check it beats."""
+ assert is_mfa_page(CHANGE_PASSWORD_MFA_LIKE_HTML)
+ assert is_sso_reload_page(snap(CHANGE_PASSWORD_RELOAD_HTML))
+
+ @pytest.mark.parametrize(
+ ("html", "kind"),
+ [
+ pytest.param(change_password_html(body=SAML_HTML), "saml", id="saml-response-wins"),
+ pytest.param(MYSTERY_HTML, "stop", id="no-config-unchanged"),
+ ],
+ )
+ def test_classify_post_mfa_precedence(self, html: str, kind: str) -> None:
+ assert classify_post_mfa(snap(html), MS_BASE).kind == kind
+
+ @pytest.mark.parametrize(
+ "html",
+ [
+ pytest.param(change_password_html(), id="plain"),
+ pytest.param(CHANGE_PASSWORD_MFA_LIKE_HTML, id="mfa-like"),
+ ],
+ )
+ def test_inline_login_after_process_auth(
+ self, scripted: ScriptedSession, isolated_config: Path, html: str
+ ) -> None:
+ scripted.enqueue(
+ *mfa_start("PhoneAppOTP"), begin_success(), end_success(), change_password_at(PROCESS_URL, html)
+ )
+ with pytest.raises(MicrosoftSSOError) as exc:
+ run_login(scripted, totp_code=TOTP_CODE)
+ assert_expired_password_error(exc, scripted)
+ assert scripted.calls[-1] == ("POST", PROCESS_URL)
+
+ @pytest.mark.parametrize(
+ "html",
+ [
+ pytest.param(change_password_html(), id="plain"),
+ pytest.param(CHANGE_PASSWORD_MFA_LIKE_HTML, id="mfa-like"),
+ ],
+ )
+ def test_legacy_form_mfa(self, scripted: ScriptedSession, isolated_config: Path, html: str) -> None:
+ scripted.enqueue(
+ *sso_start(),
+ FakeResponse(200, html=LEGACY_MFA_HTML, url=MFA_PAGE_URL),
+ change_password_at(PROCESS_URL, html),
+ )
+ with pytest.raises(MicrosoftSSOError) as exc:
+ run_login(scripted, totp_code=TOTP_CODE, mfa_method="app")
+ assert_expired_password_error(exc, scripted)
+ assert scripted.calls[-1] == ("POST", PROCESS_URL)
+
+ @staticmethod
+ def _defer_sms_login(scripted: ScriptedSession) -> None:
+ scripted.enqueue(*mfa_start("OneWaySMS", "SMS"), begin_success())
+ with pytest.raises(MfaPendingError):
+ run_login(scripted, defer_mfa_to_pending=True)
+
+ @pytest.mark.parametrize(
+ ("html", "code"),
+ [
+ pytest.param(change_password_html(), 50055, id="plain"),
+ pytest.param(CHANGE_PASSWORD_RELOAD_HTML, 50055, id="sso-reload-armed"),
+ pytest.param(change_password_html(stsErrorCode="50144"), 50144, id="code-50144"),
+ ],
+ )
+ def test_verify_clears_checkpoint(
+ self, scripted: ScriptedSession, isolated_config: Path, html: str, code: int
+ ) -> None:
+ """A checkpoint kept past EndAuth could only reach the same page again."""
+ self._defer_sms_login(scripted)
+
+ scripted.enqueue(end_success(), change_password_at(PROCESS_URL, html))
+ with pytest.raises(MicrosoftSSOError) as exc:
+ verify(scripted)
+ assert_expired_password_error(exc, scripted, code)
+ assert scripted.calls[-1] == ("POST", PROCESS_URL)
+ assert read_pending() is None
+
+ def test_verify_after_kmsi_in_walk_clears_checkpoint(
+ self, scripted: ScriptedSession, isolated_config: Path
+ ) -> None:
+ self._defer_sms_login(scripted)
+
+ scripted.enqueue(
+ end_success(),
+ FakeResponse(200, html=kmsi_html("KmsiInterrupt"), url=PROCESS_URL),
+ change_password_at(f"{MS_BASE}/common/login"),
+ )
+ with pytest.raises(MicrosoftSSOError) as exc:
+ verify(scripted)
+ assert_expired_password_error(exc, scripted)
+ assert scripted.calls[-1] == ("POST", f"{MS_BASE}/common/login")
+ assert read_pending() is None
+
+ def test_kmsi_resume_clears_checkpoint(self, scripted: ScriptedSession, isolated_config: Path) -> None:
+ self._defer_sms_login(scripted)
+ scripted.enqueue(
+ end_success(),
+ FakeResponse(200, html=kmsi_html("KmsiInterrupt"), url=PROCESS_URL),
+ requests.ConnectionError("connection reset at kmsi"),
+ )
+ with pytest.raises(requests.ConnectionError):
+ verify(scripted)
+ pending = read_pending()
+ assert pending is not None
+ assert "kmsi_checkpoint" in pending
+
+ scripted.enqueue(change_password_at(f"{MS_BASE}/common/login"))
+ with pytest.raises(MicrosoftSSOError) as exc:
+ verify(scripted)
+ assert_expired_password_error(exc, scripted)
+ assert scripted.calls[-1] == ("POST", f"{MS_BASE}/common/login")
+ assert read_pending() is None
+
+ def test_login_after_credentials_post(self, scripted: ScriptedSession, isolated_config: Path) -> None:
+ scripted.enqueue(*sso_start(change_password_html()))
+ with pytest.raises(MicrosoftSSOError) as exc:
+ run_login(scripted)
+ assert_expired_password_error(exc, scripted)
+ assert scripted.calls[-1] == ("POST", CREDS_POST_URL)
+
+ def test_probe_after_credentials_post_keeps_checkpoint(
+ self, scripted: ScriptedSession, isolated_config: Path
+ ) -> None:
+ """The probe never owns the MFA checkpoint, so it leaves one alone."""
+ save_mfa_pending({"mfa_method": "sms", "created_at": "2026-10-15T00:00:00Z"})
+ checkpoint = read_pending()
+ assert checkpoint is not None
+ scripted.enqueue(*sso_start(change_password_html()))
+ with closing(make_client(scripted)) as client, pytest.raises(MicrosoftSSOError) as exc:
+ client.probe_mfa_methods(USERNAME, PASSWORD)
+ assert_expired_password_error(exc, scripted)
+ assert scripted.calls[-1] == ("POST", CREDS_POST_URL)
+ assert read_pending() == checkpoint
From 0880a4e9f060b4b3f7cb085858078f8e69e53f45 Mon Sep 17 00:00:00 2001
From: rabesss <26330469+rabesss@users.noreply.github.com>
Date: Thu, 15 Oct 2026 11:44:36 +0530
Subject: [PATCH 2/2] fix(auth): keep one list of expired-password codes; pin
the hiddenform case
- PASSWORD_EXPIRED_CODES lives in ms_errors and both ms_auth and the auth
sanitizer use it, so a code added later can't be recognised by the walk
but miss the fixed message (DeepSeek V4.1 Flash, GLM 5.3).
- A change-password page that also carries an auto-submit hiddenform is
still classified as password_change and never POSTed, in the pure router
and in the inline login walk (DeepSeek V4.1 Flash).
- docs/auth-microsoft-sso.md describes the page and the post-MFA checkpoint
clear (DeepSeek V4.1 Flash).
---
.secrets.baseline | 10 +++++-----
docs/auth-microsoft-sso.md | 14 +++++++++++++-
lighthouse_cli/auth.py | 3 ++-
lighthouse_cli/ms_auth.py | 7 +++----
lighthouse_cli/ms_errors.py | 4 ++++
tests/test_ms_auth_characterization.py | 8 +++++++-
6 files changed, 34 insertions(+), 12 deletions(-)
diff --git a/.secrets.baseline b/.secrets.baseline
index 847d94f..e63749e 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -333,7 +333,7 @@
"filename": "tests/test_ms_auth_characterization.py",
"hashed_secret": "a936040fc880f71bff121baf4f243117f4207a44",
"is_verified": false,
- "line_number": 45,
+ "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": 258,
+ "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": 1050,
+ "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": 1145,
+ "line_number": 1148,
"is_secret": false
}
],
@@ -392,5 +392,5 @@
}
]
},
- "generated_at": "2026-10-15T05:40:37Z"
+ "generated_at": "2026-10-15T06:14:21Z"
}
diff --git a/docs/auth-microsoft-sso.md b/docs/auth-microsoft-sso.md
index 43e7efb..63f8d27 100644
--- a/docs/auth-microsoft-sso.md
+++ b/docs/auth-microsoft-sso.md
@@ -149,7 +149,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)
@@ -189,6 +191,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 a1954b4..55ffd66 100644
--- a/lighthouse_cli/auth.py
+++ b/lighthouse_cli/auth.py
@@ -44,6 +44,7 @@
MFA_METHOD_CHOOSE,
MFA_METHOD_PUSH,
MFA_METHOD_SMS,
+ PASSWORD_EXPIRED_CODES,
VALID_MFA_METHODS,
MfaPendingError,
MicrosoftSSOError,
@@ -86,7 +87,7 @@ class _PromptUnavailableError(Exception):
r"(?i)--(?:pass(?:word)?|token|secret)\s+[^\s,;{}\[\]]+"
)
_AUTH_CODE_RE = re.compile(r"(?:^|:\s*)\[(\d{3,8})\]\s*")
-_PASSWORD_EXPIRED_CODES = frozenset({"50055", "50144"})
+_PASSWORD_EXPIRED_CODES = frozenset(str(code) for code in PASSWORD_EXPIRED_CODES)
_AUTH_UNSAFE_BRACKET_RE = re.compile(r"[{}]|\[(?!\d{3,8}\])|(? bool:
)
-_PASSWORD_EXPIRED_CODES = frozenset({50055, 50144})
-
def password_change_code(snapshot: ResponseSnapshot) -> int | None:
"""The AADSTS code of a "change your password" page, else None.
@@ -359,7 +358,7 @@ def password_change_code(snapshot: ResponseSnapshot) -> int | 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
+ return code if code in PASSWORD_EXPIRED_CODES else 50055
def describe_page_shape(snapshot: ResponseSnapshot) -> str:
@@ -439,7 +438,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 in _PASSWORD_EXPIRED_CODES:
+ 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."
diff --git a/lighthouse_cli/ms_errors.py b/lighthouse_cli/ms_errors.py
index 31c33ba..ed5da06 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_ms_auth_characterization.py b/tests/test_ms_auth_characterization.py
index 2efd737..8a66c5b 100644
--- a/tests/test_ms_auth_characterization.py
+++ b/tests/test_ms_auth_characterization.py
@@ -30,6 +30,7 @@
classify_post_mfa,
describe_page_shape,
is_error_page,
+ is_hiddenform_page,
is_mfa_page,
is_sso_reload_page,
password_change_code,
@@ -248,6 +249,8 @@ def change_password_html(body: str = "", **overrides: Any) -> str:
)
# Also armed for the session-pull re-POST of the echoed credentials.
CHANGE_PASSWORD_RELOAD_HTML = change_password_html(urlPost=SSO_RELOAD_URL_POST, iSessionPullType=2)
+# Also carries an auto-submit form that would be POSTed to its action.
+CHANGE_PASSWORD_HIDDENFORM_HTML = change_password_html(body=HIDDENFORM_HTML)
# Same-host lookalikes origin pinning must refuse: plain http, a suffix-spoofed
@@ -1631,6 +1634,7 @@ def test_password_change_code_needs_the_page_id(self, html: str) -> None:
pytest.param(change_password_html(), 50055, id="plain"),
pytest.param(CHANGE_PASSWORD_MFA_LIKE_HTML, 50055, id="beats-mfa-heuristics"),
pytest.param(CHANGE_PASSWORD_RELOAD_HTML, 50055, id="beats-sso-reload"),
+ pytest.param(CHANGE_PASSWORD_HIDDENFORM_HTML, 50055, id="beats-hiddenform"),
pytest.param(change_password_html(stsErrorCode="50144"), 50144, id="code-50144"),
],
)
@@ -1640,9 +1644,10 @@ def test_classify_post_mfa_stops_on_expired_password(self, html: str, code: int)
assert transition.error_code == code
def test_variants_would_otherwise_route_elsewhere(self) -> None:
- """Guards the two variants above: each really trips the check it beats."""
+ """Guards the variants above: each really trips the check it beats."""
assert is_mfa_page(CHANGE_PASSWORD_MFA_LIKE_HTML)
assert is_sso_reload_page(snap(CHANGE_PASSWORD_RELOAD_HTML))
+ assert is_hiddenform_page(CHANGE_PASSWORD_HIDDENFORM_HTML)
@pytest.mark.parametrize(
("html", "kind"),
@@ -1659,6 +1664,7 @@ def test_classify_post_mfa_precedence(self, html: str, kind: str) -> None:
[
pytest.param(change_password_html(), id="plain"),
pytest.param(CHANGE_PASSWORD_MFA_LIKE_HTML, id="mfa-like"),
+ pytest.param(CHANGE_PASSWORD_HIDDENFORM_HTML, id="hiddenform-like"),
],
)
def test_inline_login_after_process_auth(