Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion docs/auth-microsoft-sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lighthouse_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
MFA_METHOD_CHOOSE,
MFA_METHOD_PUSH,
MFA_METHOD_SMS,
PASSWORD_EXPIRED_CODES,
VALID_MFA_METHODS,
MfaPendingError,
MicrosoftSSOError,
Expand Down Expand Up @@ -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}\])|(?<!\])\]")
_AUTH_SAFE_TYPE_RE = re.compile(r"^Unexpected error \([A-Za-z0-9_.]+\)\.")

Expand Down Expand Up @@ -229,6 +231,11 @@ def _safe_mfa_recovery(value: object) -> 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"):
Expand Down
58 changes: 47 additions & 11 deletions lighthouse_cli/ms_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -749,15 +771,21 @@ 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.
"""
saml = extract_saml_response(snapshot.html)
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")

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve human checkpoints during unattended sign-in

When a stored-authenticator sign-in reaches this page after ProcessAuth, checkpoint_kmsi is true even though login(totp_provider=...) has disabled checkpoint ownership via _checkpoint_enabled = False. This branch therefore deletes an existing human SMS or push checkpoint that the unattended flow did not create, preventing auth verify from resuming it. Check _checkpoint_enabled before clearing, as the other checkpoint operations do.

Suggested change
if checkpoint_kmsi:
if checkpoint_kmsi and self._checkpoint_enabled:

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(
Expand Down
4 changes: 4 additions & 0 deletions lighthouse_cli/ms_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
48 changes: 47 additions & 1 deletion tests/test_auth_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions tests/test_ms_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
Loading
Loading