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
3 changes: 3 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/application/commands/start_password_sign_in_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 3 additions & 1 deletion src/application/dtos/password_sign_in_response.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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 | 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
10 changes: 10 additions & 0 deletions src/application/dtos/session_cookie.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/application/services/password_sign_in_responder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions src/domain/sign_in/session_cookies.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/domain/sign_in/sign_in_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 18 additions & 3 deletions src/infrastructure/password_signin/sign_in_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +32,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.",
Expand Down Expand Up @@ -62,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
Expand All @@ -72,7 +76,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"
Expand All @@ -84,6 +92,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."""
Expand Down Expand Up @@ -131,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
Expand Down Expand Up @@ -220,14 +235,14 @@ 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
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)
Expand Down
86 changes: 83 additions & 3 deletions tests/test_password_signin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import threading
import time
from types import SimpleNamespace

import entrance
import pytest
Expand All @@ -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"
Expand Down Expand Up @@ -240,7 +253,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"),
Expand All @@ -259,6 +276,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)
Expand Down Expand Up @@ -553,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):
Expand Down Expand Up @@ -603,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
Expand All @@ -612,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)


Expand Down
Loading