From a1bb4fa60f007a0cc345d0f5de32691458ad7abd Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:39:46 +0100 Subject: [PATCH 1/2] Reported expired and missing Microsoft passwords on the password sign-in Entrance 2.1.0 raises PasswordExpired (AADSTS50055, 50144) and NoPassword (50056) instead of InvalidCredentials. They now end a sign-in with password_expired or no_password and their own message. Neither counts against the wrong-password limiter or replaces an earlier sign-in. Every failed sign-in logs the AADSTS number Microsoft gave, digits only. --- requirements.txt | 2 +- .../start_password_sign_in_command.py | 2 +- .../dtos/password_sign_in_response.py | 2 +- src/domain/sign_in/sign_in_state.py | 1 + .../password_signin/sign_in_job.py | 16 +++++++- tests/test_password_signin.py | 40 ++++++++++++++++++- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index b40c92b..ffb7f8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ uvicorn==0.38.0 httpx==0.28.1 python-multipart==0.0.20 outnote==2.3.0 -ms-entrance==2.0.0 +ms-entrance==2.1.0 wreq==0.13.0 python-docx==1.2.0 openpyxl==3.1.5 diff --git a/src/application/commands/start_password_sign_in_command.py b/src/application/commands/start_password_sign_in_command.py index ff73b6f..8b4f708 100644 --- a/src/application/commands/start_password_sign_in_command.py +++ b/src/application/commands/start_password_sign_in_command.py @@ -35,6 +35,6 @@ async def handle(self, command: StartPasswordSignInCommand) -> PasswordSignInRes del password await self._store.wait_settled(job, -1, self.SIGN_IN_WAIT) snap = job.snapshot() - if snap["state"] != SignInState.WORKING and snap.get("code") != "invalid_credentials": + if snap["state"] != SignInState.WORKING and snap.get("code") not in SignInState.PASSWORD_REFUSED: self._store.promote(job) # the password passed: this replaces the user's earlier attempt return await self._responder.respond(job) diff --git a/src/application/dtos/password_sign_in_response.py b/src/application/dtos/password_sign_in_response.py index 6e67707..597d31a 100644 --- a/src/application/dtos/password_sign_in_response.py +++ b/src/application/dtos/password_sign_in_response.py @@ -12,5 +12,5 @@ class PasswordSignInResponse(BaseModel): attempts_left: int | None = None refresh_token: str | None = None account: LinkedIdentity | None = None - code: str | None = Field(None, description="error: invalid_credentials | unsupported | blocked | rate_limited | failed") + code: str | None = Field(None, description="error: invalid_credentials (wrong email or password) | password_expired (change it at office.com first) | no_password (passwordless account: use the app's Microsoft sign-in) | unsupported | blocked | otp_exhausted | rate_limited | failed") message: str | None = None diff --git a/src/domain/sign_in/sign_in_state.py b/src/domain/sign_in/sign_in_state.py index 2ecdfc3..1464681 100644 --- a/src/domain/sign_in/sign_in_state.py +++ b/src/domain/sign_in/sign_in_state.py @@ -6,3 +6,4 @@ class SignInState: DENIED, TIMED_OUT, COMPLETE, ERROR = "denied", "timed_out", "complete", "error" TRANSIENT = {WORKING, STARTING_PUSH, STARTING_OTP, VERIFYING} CHOOSABLE = {MFA_REQUIRED, DENIED, TIMED_OUT} # states where /method picks a (new) method + PASSWORD_REFUSED = frozenset({"invalid_credentials", "password_expired", "no_password"}) # error codes for "Microsoft did not take the password" diff --git a/src/infrastructure/password_signin/sign_in_job.py b/src/infrastructure/password_signin/sign_in_job.py index 80c8907..6bde122 100644 --- a/src/infrastructure/password_signin/sign_in_job.py +++ b/src/infrastructure/password_signin/sign_in_job.py @@ -31,6 +31,8 @@ class SignInJob(MfaPrompt, ISignInJob): OTP_ATTEMPTS = entrance.OTP_ATTEMPTS MESSAGES = { "invalid_credentials": "Wrong email or password.", + "password_expired": "Your Microsoft password has expired. Change it at office.com, then try again.", + "no_password": "This Microsoft account has no password. Sign in with the Schuly app instead.", "unsupported": "This Microsoft account can't be signed in with a password here.", "blocked": "Microsoft blocked this sign-in because of a security policy.", "failed": "The Microsoft sign-in failed.", @@ -72,7 +74,11 @@ def __init__(self, job_id: str, user: str, account: str, failures: RateLimiter, def failure_for(cls, ex: BaseException) -> tuple[str, str]: """Map a sign-in exception to a stable (code, message). Only the exception TYPE decides; the exception text is never copied into a response.""" - if isinstance(ex, entrance.InvalidCredentials): + if isinstance(ex, entrance.PasswordExpired): + code = "password_expired" + elif isinstance(ex, entrance.NoPassword): + code = "no_password" + elif isinstance(ex, entrance.InvalidCredentials): code = "invalid_credentials" elif isinstance(ex, entrance.OtpRejected): code = "otp_exhausted" @@ -84,6 +90,12 @@ def failure_for(cls, ex: BaseException) -> tuple[str, str]: code = "failed" return code, cls.MESSAGES[code] + @staticmethod + def aadsts_of(ex: BaseException) -> str: + """The AADSTS number Microsoft gave for a failed sign-in, digits only, or "-" when it named none.""" + code = getattr(ex, "code", None) if isinstance(ex, entrance.LoginFailed) else None + return code if isinstance(code, str) and code.isdigit() and len(code) <= 10 else "-" + # -- state --------------------------------------------------------------------------------- def _set(self, state: str, **fields): """Move to a state (caller holds `cond`) and wake every waiter.""" @@ -227,7 +239,7 @@ def run(self, username: str, secret: Secret): code, message = self.failure_for(ex) if code == "invalid_credentials": self._failures.add(self.acct) # only failed password attempts count against the limiter - self.log.warning("password sign-in %s ended: %s (%s)", self.id, code, type(ex).__name__) + self.log.warning("password sign-in %s ended: %s (%s, AADSTS %s)", self.id, code, type(ex).__name__, self.aadsts_of(ex)) with self.cond: self.done_at = time.monotonic() self._set(SignInState.ERROR, error_code=code, error_message=message) diff --git a/tests/test_password_signin.py b/tests/test_password_signin.py index e1c4eef..fe6ab44 100644 --- a/tests/test_password_signin.py +++ b/tests/test_password_signin.py @@ -240,7 +240,11 @@ def test_method_to_the_current_method_is_a_no_op(api, monkeypatch): @pytest.mark.parametrize( "error, code", [ - (entrance.InvalidCredentials("Wrong username or password"), "invalid_credentials"), + (entrance.InvalidCredentials("Wrong username or password", code="50126"), "invalid_credentials"), + (entrance.AccountNotFound("No account with that username"), "invalid_credentials"), + (entrance.PasswordExpired("expired", code="50144"), "password_expired"), + (entrance.PasswordExpired("expired", code="50055"), "password_expired"), + (entrance.NoPassword("no password", code="50056"), "no_password"), (entrance.FederatedAccount(redirect_url="https://adfs.school.ch/adfs"), "unsupported"), (entrance.UnsupportedMfa("only sms", offered=["OneWaySMS"]), "unsupported"), (entrance.Blocked("blocked", code="53003"), "blocked"), @@ -259,6 +263,40 @@ def fail(prompt): assert "refresh_token" not in body +@pytest.mark.parametrize( + "error, logged", + [ + (entrance.PasswordExpired("expired", code="50144"), "password_expired (PasswordExpired, AADSTS 50144)"), + (entrance.InvalidCredentials("Wrong username or password", code="50126"), "invalid_credentials (InvalidCredentials, AADSTS 50126)"), + (entrance.AccountNotFound("No account with that username"), "invalid_credentials (AccountNotFound, AADSTS -)"), + (entrance.LoginFailed("odd", code=USER), "failed (LoginFailed, AADSTS -)"), + (RuntimeError("boom"), "failed (RuntimeError, AADSTS -)"), + ], +) +def test_failure_logs_the_aadsts_number_only(api, monkeypatch, caplog, error, logged): + caplog.set_level(logging.INFO) + + def fail(prompt): + raise error + + scripted(monkeypatch, fail) + start(api) + assert logged in caplog.text + assert USER not in caplog.text and PASSWORD not in caplog.text + + +@pytest.mark.parametrize("error", [entrance.PasswordExpired("expired", code="50144"), entrance.NoPassword("none", code="50056")]) +def test_refused_password_does_not_count_or_replace(api, monkeypatch, error): + monkeypatch.setattr(sign_in_options, "account_limit", 1) + + def fail(prompt): + raise error + + scripted(monkeypatch, fail) + for _ in range(3): # not a wrong password, so the failure limiter stays untouched + assert start(api).json()["code"] in ("password_expired", "no_password") + + def test_failure_after_choosing_a_method(api, monkeypatch): def script(prompt): prompt.choose(BOTH) From bb33a8b7a0774f27adb1a82e2fbcf1f2a51deb5f Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:55:38 +0100 Subject: [PATCH 2/2] Returned the Microsoft sign-in cookies from the password sign-in A completed password sign-in now answers session_cookies next to the refresh token: the cookies of Microsoft's sign-in hosts from Entrance's Broker, in the shape the app's WebView capture sends. Schulnetz needs them to sign in with a Microsoft account linked on the web. They are never logged. --- .claude/CLAUDE.md | 3 ++ .../dtos/password_sign_in_response.py | 2 + src/application/dtos/session_cookie.py | 10 ++++ .../services/password_sign_in_responder.py | 2 +- src/domain/sign_in/session_cookies.py | 18 ++++++++ .../password_signin/sign_in_job.py | 5 +- tests/test_password_signin.py | 46 ++++++++++++++++++- 7 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 src/application/dtos/session_cookie.py create mode 100644 src/domain/sign_in/session_cookies.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a4e3823..c122523 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -20,6 +20,9 @@ The Schuly **Microsoft plugin** is the only caller. never the password, so the service **must run as ONE process** (`asgi.py` pins `workers=1`; do not add uvicorn/gunicorn workers or replicas). Keep the password out of logs, job objects, errors and responses (the 422 handler for `/api/auth/password*` strips the echoed input on purpose). +- A completed password sign-in also returns `session_cookies`: Entrance's `Broker.session_cookies`, filtered to + Microsoft's sign-in hosts (`SessionCookies`) in the WebView capture's `{domain, name, path, value}` shape. Schulnetz + needs them to sign in with the linked account. They are a credential like the refresh token: never log them. - Uses only the **public** OutNote/Entrance API (`outnote.OutNote`, `entrance.Broker`, `entrance.http_client`) plus the documented OAuth calls for the browser and device flows. Don't reach into their private modules. Needs Python 3.11 or newer. The one non-Graph surface is the Teams chat service for group diff --git a/src/application/dtos/password_sign_in_response.py b/src/application/dtos/password_sign_in_response.py index 597d31a..3b3719b 100644 --- a/src/application/dtos/password_sign_in_response.py +++ b/src/application/dtos/password_sign_in_response.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, Field from src.application.dtos.linked_identity import LinkedIdentity +from src.application.dtos.session_cookie import SessionCookie class PasswordSignInResponse(BaseModel): @@ -11,6 +12,7 @@ class PasswordSignInResponse(BaseModel): expires_in: int | None = Field(None, description="push_pending: seconds left to approve.") attempts_left: int | None = None refresh_token: str | None = None + session_cookies: list[SessionCookie] | None = Field(None, description="complete: the Microsoft sign-in session (cookies of login.microsoftonline.com, login.microsoft.com and login.live.com) in the shape of the app's WebView capture, for the caller to store encrypted. A credential, like refresh_token: never log it.") account: LinkedIdentity | None = None code: str | None = Field(None, description="error: invalid_credentials (wrong email or password) | password_expired (change it at office.com first) | no_password (passwordless account: use the app's Microsoft sign-in) | unsupported | blocked | otp_exhausted | rate_limited | failed") message: str | None = None diff --git a/src/application/dtos/session_cookie.py b/src/application/dtos/session_cookie.py new file mode 100644 index 0000000..98e02ed --- /dev/null +++ b/src/application/dtos/session_cookie.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class SessionCookie(BaseModel): + """One cookie of the Microsoft sign-in session, as the app's WebView capture sends it.""" + + domain: str + name: str + path: str + value: str diff --git a/src/application/services/password_sign_in_responder.py b/src/application/services/password_sign_in_responder.py index 7ff5887..8c6a162 100644 --- a/src/application/services/password_sign_in_responder.py +++ b/src/application/services/password_sign_in_responder.py @@ -17,7 +17,7 @@ async def respond(self, job: ISignInJob) -> PasswordSignInResponse: if state == SignInState.COMPLETE: if job.account is None: job.account = await self._identities.read(snap["refresh_token"]) - return PasswordSignInResponse(**base, status="complete", refresh_token=snap["refresh_token"], account=job.account) + return PasswordSignInResponse(**base, status="complete", refresh_token=snap["refresh_token"], session_cookies=snap.get("session_cookies") or None, account=job.account) if state == SignInState.ERROR: return PasswordSignInResponse(**base, status="error", code=snap["code"], message=snap["message"]) if state == SignInState.PUSH_PENDING: diff --git a/src/domain/sign_in/session_cookies.py b/src/domain/sign_in/session_cookies.py new file mode 100644 index 0000000..e92b226 --- /dev/null +++ b/src/domain/sign_in/session_cookies.py @@ -0,0 +1,18 @@ +class SessionCookies: + """The Microsoft sign-in session a password sign-in ends with, in the shape the app's WebView capture sends: + `{domain, name, path, value}`, only for Microsoft's sign-in hosts. A credential: never log it.""" + + SIGN_IN_HOSTS = frozenset({"login.microsoftonline.com", "login.microsoft.com", "login.live.com"}) + + @classmethod + def sign_in_only(cls, cookies) -> list[dict[str, str]]: + kept = [] + for cookie in cookies or []: + if not isinstance(cookie, dict): + continue + domain = str(cookie.get("domain") or "").strip().lstrip(".").lower() + name, value, path = cookie.get("name"), cookie.get("value"), cookie.get("path") + if domain not in cls.SIGN_IN_HOSTS or not isinstance(name, str) or not name.strip() or not isinstance(value, str): + continue + kept.append({"domain": domain, "name": name, "path": path if isinstance(path, str) and path.strip() else "/", "value": value}) + return kept diff --git a/src/infrastructure/password_signin/sign_in_job.py b/src/infrastructure/password_signin/sign_in_job.py index 6bde122..b85f399 100644 --- a/src/infrastructure/password_signin/sign_in_job.py +++ b/src/infrastructure/password_signin/sign_in_job.py @@ -20,6 +20,7 @@ from src.application.abstractions.i_sign_in_job import ISignInJob from src.application.options.password_sign_in_options import PasswordSignInOptions +from src.domain.sign_in.session_cookies import SessionCookies from src.domain.sign_in.sign_in_state import SignInState from src.infrastructure.logging_config import LoggerNames from src.infrastructure.password_signin.rate_limiter import RateLimiter @@ -64,6 +65,7 @@ def __init__(self, job_id: str, user: str, account: str, failures: RateLimiter, self.requested: str | None = None self.code: str | None = None # a one-time authenticator code, taken (cleared) by the worker self.refresh_token: str | None = None + self.session_cookies: list[dict[str, str]] = [] # the Microsoft sign-in session, handed to the caller like the refresh token self.account = None self.error_code: str | None = None self.error_message: str | None = None @@ -143,6 +145,7 @@ def snapshot(self) -> dict: snap["otp_invalid"] = self.otp_invalid if self.state == SignInState.COMPLETE: snap["refresh_token"] = self.refresh_token + snap["session_cookies"] = list(self.session_cookies) if self.state == SignInState.ERROR: snap["code"], snap["message"] = self.error_code, self.error_message return snap @@ -232,7 +235,7 @@ def run(self, username: str, secret: Secret): broker = entrance.foci_login(username, secret, cookies=None, frt_store=None, mfa=self, push_timeout=self._options.push_timeout, push_interval=self._options.push_interval) with self.cond: self.done_at = time.monotonic() - self._set(SignInState.COMPLETE, refresh_token=broker.refresh_token) + self._set(SignInState.COMPLETE, refresh_token=broker.refresh_token, session_cookies=SessionCookies.sign_in_only(getattr(broker, "session_cookies", None))) except MfaCancelled: self.log.info("password sign-in %s cancelled", self.id) except BaseException as ex: # noqa: BLE001 - the worker must always end in a terminal state diff --git a/tests/test_password_signin.py b/tests/test_password_signin.py index fe6ab44..d52a2a1 100644 --- a/tests/test_password_signin.py +++ b/tests/test_password_signin.py @@ -5,6 +5,7 @@ import logging import threading import time +from types import SimpleNamespace import entrance import pytest @@ -25,8 +26,20 @@ def me(self): class FakeBroker: - def __init__(self, refresh_token="frt-new"): + def __init__(self, refresh_token="frt-new", session_cookies=None): self.refresh_token = refresh_token + self.session_cookies = session_cookies or [] + + +FAKE_ESTS = "fake-ests-cookie-value" +SIGN_IN_JAR = [ + {"name": "ESTSAUTHPERSISTENT", "value": FAKE_ESTS, "domain": ".login.microsoftonline.com", "path": "/"}, + {"name": "buid", "value": "fake-buid", "domain": "login.microsoftonline.com", "path": ""}, + {"name": "MSPAuth", "value": "fake-msp", "domain": "LOGIN.LIVE.COM", "path": "/ppsecure"}, + {"name": "OH.SID", "value": "fake-other", "domain": ".www.office.com", "path": "/"}, + {"name": "", "value": "no-name", "domain": "login.microsoft.com", "path": "/"}, + {"name": "nul", "value": None, "domain": "login.microsoft.com", "path": "/"}, +] KEY = "test-service-key" @@ -591,7 +604,8 @@ class _MsSession: def __init__(self, *a, **k): self.headers = {} - self.cookies = type("J", (), {"jar": [], "set": lambda *a, **k: None})() + jar = [SimpleNamespace(name="ESTSAUTH", value="fake-real-ests", domain=".login.microsoftonline.com", path="/")] + self.cookies = type("J", (), {"jar": jar, "set": lambda *a, **k: None})() self.polls = 0 def get(self, url, **kw): @@ -641,6 +655,33 @@ def get(self, url, **kw): monkeypatch.setattr(ef, "_post_token", lambda url, data: {"refresh_token": "real-frt", "access_token": "at"}) +def test_complete_hands_back_only_the_sign_in_cookies(api, monkeypatch, caplog): + caplog.set_level(logging.DEBUG) + scripted(monkeypatch, lambda p: FakeBroker(session_cookies=SIGN_IN_JAR)) + body = start(api).json() + assert body["status"] == "complete" + assert body["session_cookies"] == [ + {"domain": "login.microsoftonline.com", "name": "ESTSAUTHPERSISTENT", "path": "/", "value": FAKE_ESTS}, + {"domain": "login.microsoftonline.com", "name": "buid", "path": "/", "value": "fake-buid"}, + {"domain": "login.live.com", "name": "MSPAuth", "path": "/ppsecure", "value": "fake-msp"}, + ] + assert FAKE_ESTS not in caplog.text + + +def test_complete_without_cookies_leaves_the_field_out(api, monkeypatch): + scripted(monkeypatch, lambda p: FakeBroker()) + body = start(api).json() + assert body["status"] == "complete" and "session_cookies" not in body + + +def test_a_failed_sign_in_hands_back_no_cookies(api, monkeypatch): + def fail(prompt): + raise entrance.InvalidCredentials("Wrong username or password", code="50126") + + scripted(monkeypatch, fail) + assert "session_cookies" not in start(api).json() + + def test_real_entrance_push(api, real_entrance): body = start(api).json() assert body["status"] == "mfa_required" and body["methods"] == BOTH @@ -650,6 +691,7 @@ def test_real_entrance_push(api, real_entrance): _MsSession.gate.set() done = until(api, sid, "complete") assert done["refresh_token"] == "real-frt" and done["account"]["email"] == USER + assert done["session_cookies"] == [{"domain": "login.microsoftonline.com", "name": "ESTSAUTH", "path": "/", "value": "fake-real-ests"}] assert not reaches(sign_in_store, PASSWORD)