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 ( + "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) +# 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 # host, userinfo, and a non-default port. UNSAFE_MS_URLS = [ @@ -1534,3 +1575,207 @@ 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_HIDDENFORM_HTML, 50055, id="beats-hiddenform"), + 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 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"), + [ + 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"), + pytest.param(CHANGE_PASSWORD_HIDDENFORM_HTML, id="hiddenform-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