From 55e806ba7338095a15f052f6a78a6700404d368b Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:36:03 +0530 Subject: [PATCH 01/12] feat(auth): opt-in unattended sign-in with a stored authenticator MAHE's Entra sign-in frequency ends the session about every 5 days and always asks for a second factor again. With an authenticator-app secret stored by `auth totp-setup`, the CLI completes the PhoneAppOTP step itself, so an agent keeps working without a person re-entering the password and MFA. - `auth totp-setup` / `auth totp-remove`: seal or delete the secret (hidden prompt or stdin, never argv), bound to the saved username. - `auth login` uses the stored authenticator when no `--totp` is given and `--mfa-method` is unset or `app`; the code is generated after BeginAuth, submitted once and never shown. - Replay-safe reads on the built-in connection sign in once and retry once when the session is expired or the cookies are missing. An expired session can answer 403, so a 403 counts as expiry only when whoami is signed out too. Writes, quiz flows and read-only clients never sign in. - `session_renewal`: cross-process `renewal_lock`, strictly advancing time steps, and the `relogin_state.json` breaker that stops repeated failed sign-ins. - `auth status` checks whoami and reports `renewed` and `unattended_login`. Docs: docs/auth-unattended.md (setup, failure categories, trade-off). --- .secrets.baseline | 38 +- AGENTS.md | 17 +- README.md | 63 +- ...headless-browser-auth-with-flexible-2fa.md | 7 + docs/auth-microsoft-sso.md | 17 + docs/auth-unattended.md | 202 +++++ docs/runbooks/auth-expiry-recovery.md | 13 + lighthouse_cli/api.py | 241 ++++- lighthouse_cli/auth.py | 306 ++++++- lighthouse_cli/cli.py | 32 + lighthouse_cli/commands.py | 43 +- lighthouse_cli/config.py | 12 + lighthouse_cli/credential_store.py | 135 ++- lighthouse_cli/display.py | 14 + lighthouse_cli/ms_auth.py | 198 ++++- lighthouse_cli/ms_errors.py | 47 +- lighthouse_cli/ms_mfa.py | 1 + lighthouse_cli/session_import.py | 36 +- lighthouse_cli/session_renewal.py | 562 ++++++++++++ lighthouse_cli/totp.py | 158 ++++ pyproject.toml | 7 +- tests/_relogin_mp_helper.py | 106 +++ tests/test_api_transport.py | 831 ++++++++++++++++++ tests/test_assessment_workflows.py | 47 +- tests/test_auth_login.py | 443 +++++++++- tests/test_auth_totp.py | 345 ++++++++ tests/test_cli_contracts.py | 196 ++++- tests/test_credential_store.py | 162 ++++ tests/test_ms_auth_provider.py | 528 +++++++++++ tests/test_read_only_auth.py | 22 + tests/test_secret_absence.py | 148 ++++ tests/test_secrets_store.py | 3 +- tests/test_session_renewal.py | 690 +++++++++++++++ tests/test_session_renewal_multiprocess.py | 184 ++++ tests/test_totp.py | 157 ++++ 35 files changed, 5839 insertions(+), 172 deletions(-) create mode 100644 docs/auth-unattended.md create mode 100644 lighthouse_cli/session_renewal.py create mode 100644 lighthouse_cli/totp.py create mode 100644 tests/_relogin_mp_helper.py create mode 100644 tests/test_auth_totp.py create mode 100644 tests/test_ms_auth_provider.py create mode 100644 tests/test_session_renewal.py create mode 100644 tests/test_session_renewal_multiprocess.py create mode 100644 tests/test_totp.py diff --git a/.secrets.baseline b/.secrets.baseline index adab5a0..7a0443a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -149,7 +149,7 @@ "filename": "README.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 343, + "line_number": 365, "is_secret": false } ], @@ -169,7 +169,7 @@ "filename": "tests/test_api_transport.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 740, + "line_number": 750, "is_secret": false } ], @@ -179,7 +179,7 @@ "filename": "tests/test_assessment_workflows.py", "hashed_secret": "f714a8256826794cf5e9c3aa49c17d9ea53b8bd5", "is_verified": false, - "line_number": 156, + "line_number": 159, "is_secret": false } ], @@ -189,7 +189,7 @@ "filename": "tests/test_auth_login.py", "hashed_secret": "f32b67c7e26342af42efabc674d441dca0a281c5", "is_verified": false, - "line_number": 361, + "line_number": 369, "is_secret": false }, { @@ -197,7 +197,7 @@ "filename": "tests/test_auth_login.py", "hashed_secret": "e6f8cac5c7482fb7e8de06675b6fc585089bf0d7", "is_verified": false, - "line_number": 740, + "line_number": 748, "is_secret": false }, { @@ -205,7 +205,7 @@ "filename": "tests/test_auth_login.py", "hashed_secret": "fdda285858f81043adc365d4533985b6d804c5c2", "is_verified": false, - "line_number": 741, + "line_number": 749, "is_secret": false } ], @@ -215,7 +215,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "7ce0359f12857f2a90c7de465f40a95f01cb5da9", "is_verified": false, - "line_number": 72, + "line_number": 77, "is_secret": false }, { @@ -223,7 +223,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "179e114fd911d7221c35fd34521b5d028c7228c6", "is_verified": false, - "line_number": 91, + "line_number": 96, "is_secret": false }, { @@ -231,7 +231,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "d042a2aabc2178b93d8ee042f1965e01c02db5ba", "is_verified": false, - "line_number": 219, + "line_number": 224, "is_secret": false }, { @@ -239,7 +239,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "19083abd4df9df9ddec4ff8f8f2d04b3394394e7", "is_verified": false, - "line_number": 291, + "line_number": 296, "is_secret": false }, { @@ -247,7 +247,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "467a24615d843d998a1579ca537fb498a1880a23", "is_verified": false, - "line_number": 491, + "line_number": 649, "is_secret": false }, { @@ -255,7 +255,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "2dc5053699a351121bf839c446bd4a878dda5735", "is_verified": false, - "line_number": 506, + "line_number": 664, "is_secret": false }, { @@ -263,7 +263,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "44ae3aa5a71fbc4b2b1bc30a17ba4318cd7feb9b", "is_verified": false, - "line_number": 1185, + "line_number": 1379, "is_secret": false }, { @@ -271,7 +271,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "09aaa0ab2b1665c54cf74694200328fb65652628", "is_verified": false, - "line_number": 1186, + "line_number": 1380, "is_secret": false } ], @@ -281,7 +281,7 @@ "filename": "tests/test_credential_store.py", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 204, + "line_number": 206, "is_secret": false }, { @@ -289,7 +289,7 @@ "filename": "tests/test_credential_store.py", "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", "is_verified": false, - "line_number": 281, + "line_number": 283, "is_secret": false } ], @@ -367,7 +367,7 @@ "filename": "tests/test_secret_absence.py", "hashed_secret": "14674a093253dbe7059f22c9cbe12028f540642d", "is_verified": false, - "line_number": 28, + "line_number": 32, "is_secret": false } ], @@ -377,7 +377,7 @@ "filename": "tests/test_secrets_store.py", "hashed_secret": "c13315c2a540720f880d4c9e34657be1f9626a37", "is_verified": false, - "line_number": 360, + "line_number": 359, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-09T17:28:09Z" + "generated_at": "2026-10-14T15:50:58Z" } diff --git a/AGENTS.md b/AGENTS.md index 3cf6807..2043742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,11 +48,16 @@ with `uv pip compile`. See the `contribution-toolchain` skill in `--json` vs human output). - `lighthouse_cli/api.py` — `LighthouseClient` (D2L REST client, cookie mgmt, course-id resolution, CDP cookie extraction for `auth refresh`). -- `lighthouse_cli/auth.py` — `cmd_auth_login` / `cmd_auth_verify`. +- `lighthouse_cli/auth.py` — `cmd_auth_login` / `cmd_auth_verify` / + `cmd_auth_totp_setup` / `cmd_auth_totp_remove`. - `lighthouse_cli/credential_store.py` — `CredentialStore`: single owner of secret sealing (Fernet envelopes; key from `LIGHTHOUSE_SECRETS_PASSPHRASE` - or the OS keyring) for `credentials.json`, `cookies.json`, and the MFA - pending checkpoint. + or the OS keyring) for `credentials.json`, `cookies.json`, the opt-in + `authenticator.json`, and the MFA pending checkpoint. +- `lighthouse_cli/session_renewal.py` — unattended sign-in with the stored + authenticator: the cross-process `renewal_lock`, time-step reservation, and + the `relogin_state.json` circuit breaker. +- `lighthouse_cli/totp.py` — RFC 6238 TOTP primitives (stdlib only). - **Microsoft SSO is split into focused modules** (do not re-monolithize): `ms_auth.py` (`MicrosoftSSOClient`), `ms_parse.py` (HTML/`$Config` parsing), `ms_session.py` (cookie/session helpers), `ms_mfa.py` (MFA proof selection), @@ -79,7 +84,11 @@ with `uv pip compile`. See the `contribution-toolchain` skill in valid for `--mfa-method app`. `TwoWayVoice*` (answer and press #) and `PhoneAppNotification` (including number matching) are codeless approvals: poll EndAuth without `AdditionalAuthData`. Resume a pending MFA session only - when its saved method matches the requested method. + when its saved method matches the requested method. The stored authenticator + (`auth totp-setup`, `docs/auth-unattended.md`) is `PhoneAppOTP` only: its + code is generated after `BeginAuth`, submitted once (any rejection is final), + and never printed. Automatic sign-in runs only for replay-safe reads on the + built-in connection, never for writes or `read_only_auth` clients. - **Typing:** keep `from __future__ import annotations`; use `X | None` (not `Optional[X]`); never use a bare `Callable` (parameterize it). - **External processes:** wrap `subprocess`, Playwright, and CDP/websocket diff --git a/README.md b/README.md index 55c7223..bb2f034 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,18 @@ graph TD with `--mfa-method app --totp `. `auth mfa-methods` lists what the account supports without starting a challenge. See [docs/auth-microsoft-sso.md](docs/auth-microsoft-sso.md). +- **Auth (unattended, opt-in):** with an authenticator-app secret stored by + `auth totp-setup`, an expired or missing session signs in again on the next + read, with no prompt. See [docs/auth-unattended.md](docs/auth-unattended.md). - **Auth (CDP — `auth refresh` only):** Session cookies (`d2lSecureSessionVal`, `d2lSessionVal`, `d2lSameSiteCanaryA`, `d2lSameSiteCanaryB`) can also be extracted from a running browser via Chrome DevTools Protocol through `browser-harness` or Python websockets. - **API:** D2L REST API — LE v1.93, LP v1.47. - **Secret storage:** All session secrets are encrypted at rest by - `CredentialStore` (Fernet): `cookies.json`, `mfa_pending.json`, and - `credentials.json` in `~/.config/lighthouse-cli/` (permissions `0600`; + `CredentialStore` (Fernet): `cookies.json`, `mfa_pending.json`, + `credentials.json`, and the opt-in `authenticator.json` in + `~/.config/lighthouse-cli/` (permissions `0600`; override the directory with `LIGHTHOUSE_CONFIG_DIR`). Only non-secret metadata (timestamps, MFA method) is stored unencrypted beside the ciphertext. See "Encryption key sources" below. @@ -100,7 +104,8 @@ graph TD and deduplication. - **Session lifetime:** Cookies expire (typically when the browser session ends or D2L rotates them). Re-run `lighthouse auth refresh` or - `lighthouse auth login` when commands fail with "Session expired". + `lighthouse auth login` when commands fail with "Session expired", or set up + [unattended sign-in](docs/auth-unattended.md). ## Upgrade notes @@ -124,7 +129,9 @@ stderr. `--help` remains human-readable. - **[READ-ONLY]:** `semesters`, `courses`, `content`, `grades`, `announcements`, `calendar`, `quizzes`, `quiz`, and `assignments` only read LMS data. -- **[LOCAL WRITE]:** `auth` stores local session state, `config courses` writes +- **[LOCAL WRITE]:** `auth` stores local session state (so do read commands + that sign in again under [unattended sign-in](docs/auth-unattended.md)), + `config courses` writes `course-config.json`, and `download`/`sync` write files and manifests under the local download root (`--output-dir`, default `~/Downloads/lighthouse`). - **[REMOTE WRITE]:** `submit` sends a file to Brightspace. Instructor @@ -304,11 +311,14 @@ page. See [Brightspace assessment protocol notes](docs/quiz-protocol.md). ### `lighthouse auth status` -Check whether the stored session cookies are still valid. +Check whether the stored session cookies are still signed in. With +[unattended sign-in](docs/auth-unattended.md) configured, an expired or missing +session signs in again first, as for any read. **Flags:** `--json` -**API call:** `GET /d2l/api/versions/` (lightweight ping) +**API call:** `GET /d2l/api/lp/1.47/users/whoami` (the response body is checked +and dropped) **Human output:** ``` @@ -319,10 +329,16 @@ Session valid. Cookies: d2lSameSiteCanaryA, d2lSameSiteCanaryB, d2lSecureSession ```json { "valid": true, - "cookies": ["d2lSameSiteCanaryA", "d2lSameSiteCanaryB", "d2lSecureSessionVal", "d2lSessionVal"] + "cookies": ["d2lSameSiteCanaryA", "d2lSameSiteCanaryB", "d2lSecureSessionVal", "d2lSessionVal"], + "renewed": false, + "unattended_login": {"configured": true, "blocked": false} } ``` +`renewed` is `true` when this command signed in again. On failure the document +has `"valid": false`, an `error`, and, if automatic sign-in failed, its +`category` (see [docs/auth-unattended.md](docs/auth-unattended.md)). + --- ### `lighthouse auth login [--user EMAIL] [--totp CODE] [--mfa-method auto|sms|app|call|push|choose] [--save-credentials] [--json]` @@ -335,6 +351,12 @@ commands. Non-interactive runs use `auth verify ` to resume the same saved challenge. See [docs/auth-microsoft-sso.md](docs/auth-microsoft-sso.md). Session cookies usually expire after ~5 days; re-run login when `auth status` fails. +With saved credentials and a stored authenticator secret (`auth totp-setup`), +`auth login` signs in with no prompt: it generates the authenticator-app code +itself. That happens only when no `--totp` is given and `--mfa-method` is +unset or `app`. Expired sessions then also sign in again unattended. See +[docs/auth-unattended.md](docs/auth-unattended.md). + **Credentials (pick one; do not commit secrets):** ```bash @@ -355,7 +377,7 @@ lighthouse auth login | `--user` | — | Username (email) for Microsoft SSO (or `LIGHTHOUSE_USERNAME` env var) | | `--totp` | — | Offline `PhoneAppOTP` code, or `-` to read a fresh SMS code after BeginAuth; rejected for voice/push | | `--mfa-method` | `choose` in an interactive terminal; `auto` otherwise | `auto`, `sms`, `app`, `call` (voice), `push` (approve), or `choose` (account method list) | -| `--save-credentials` | — | Save email/password encrypted; cookies still expire ~5 days | +| `--save-credentials` | — | Save email/password encrypted; cookies still expire ~5 days unless [unattended sign-in](docs/auth-unattended.md) is set up | | `--json` | — | Machine-readable output | **Authentication flow:** @@ -392,6 +414,24 @@ do not run `login` again before verifying). Pass the SMS/WhatsApp code, or the literal `ok` after a codeless voice-call or push approval. Required for non-TTY / agent workflows. +### `lighthouse auth totp-setup [--json]` + +Store the secret of an authenticator app registered on your Microsoft account, +so expired sessions sign in again unattended. Requires saved credentials +(`auth login --save-credentials`). The secret (base32 key or `otpauth://totp/` +URI) is read from a hidden prompt in a terminal, or one line of stdin; there is +no argument for it. The password and the second factor then share one sealed +store. Read [docs/auth-unattended.md](docs/auth-unattended.md) before using it. + +**JSON output (`--json`):** `{"success": true, "configured": true}` + +### `lighthouse auth totp-remove [--json]` + +Delete the stored authenticator secret and the automatic sign-in state. Saved +credentials and cookies stay. Also remove the app from Microsoft security info. + +**JSON output (`--json`):** `{"success": true, "removed": true}` + ### `lighthouse auth mfa-methods [--user EMAIL] [--json]` Performs a real Microsoft sign-in through the post-password stage and may @@ -1218,7 +1258,9 @@ All endpoints are relative to `https://lighthouse.manipal.edu`. - **Cookie expiration:** Cookies expire. When they do, commands instruct you to run `lighthouse auth login` and exit with code 1. `auth refresh` only - extracts cookies from an already signed-in local browser CDP session. + extracts cookies from an already signed-in local browser CDP session. With + [unattended sign-in](docs/auth-unattended.md) configured, reads sign in + again once and retry; writes never do. - **GradeObjectIdentifier vs GradeObjectId:** The `myGradeValues` API returns `GradeObjectIdentifier` (a string), not `GradeObjectId` (an int). The merge logic in `cmd_grades` handles this by trying both field names. @@ -1280,6 +1322,9 @@ programmatically. Here's the recommended workflow: $ lighthouse auth login -> {success: true, cookies: [...]} + With unattended sign-in set up (docs/auth-unattended.md), reads sign in + again by themselves; check "unattended_login" in auth status --json + 3. Use --json only on a leaf command that advertises it $ lighthouse courses --json $ lighthouse content "signals" --json diff --git a/docs/adr/0002-headless-browser-auth-with-flexible-2fa.md b/docs/adr/0002-headless-browser-auth-with-flexible-2fa.md index 45644a4..b384e1c 100644 --- a/docs/adr/0002-headless-browser-auth-with-flexible-2fa.md +++ b/docs/adr/0002-headless-browser-auth-with-flexible-2fa.md @@ -7,6 +7,13 @@ > (interactive, `--totp`, stdin pipe, env var), and offline Authenticator TOTP > now completes in one step. See > [auth-microsoft-sso.md](../auth-microsoft-sso.md). +> +> **Update (2026-10) — stored-secret TOTP implemented as opt-in.** The +> deferred option below now exists: `auth totp-setup` seals an +> authenticator-app secret next to the saved credentials, and the CLI signs in +> unattended on the `PhoneAppOTP` path. It is opt-in because it accepts the +> trade-off noted below: whoever can unseal the store holds both factors. See +> [auth-unattended.md](../auth-unattended.md). The CLI supports a full headless login flow (`auth login`) through Microsoft SSO + 2FA, not just cookie extraction from an existing browser (`auth refresh`). This is necessary because server-based agents have no local browser to extract cookies from, and D2L sessions expire every ~5 days behind Manipal's Azure AD with mandatory 2FA. diff --git a/docs/auth-microsoft-sso.md b/docs/auth-microsoft-sso.md index 43e7efb..482aab3 100644 --- a/docs/auth-microsoft-sso.md +++ b/docs/auth-microsoft-sso.md @@ -110,6 +110,21 @@ lighthouse auth login --mfa-method app --totp 123456 An explicit `--mfa-method app` starts a fresh app flow and will not resume a stale SMS `mfa_pending.json`. +### Stored authenticator (app TOTP) — unattended + +```bash +lighthouse auth totp-setup # once; secret from a hidden prompt or stdin +lighthouse auth login # no prompt +``` + +With saved credentials and a stored authenticator secret, `auth login` +(no `--totp`; `--mfa-method` unset or `app`) runs the same `PhoneAppOTP` flow. +The CLI generates the code itself after `BeginAuth` and sends exactly one +`EndAuth`; any rejection fails with `code_rejected` and is never retried. +It never writes `mfa_pending.json`. Read commands use the same path to sign in +again when the session expires. Setup, the security trade-off, and the failure +breaker are in [auth-unattended.md](auth-unattended.md). + ### Voice call — codeless approval ```bash @@ -211,6 +226,8 @@ followed manually so the SAML body is never replayed to an untrusted target. | `lighthouse_cli/ms_auth.py` | SSO + MFA + SAML implementation | | `lighthouse_cli/auth.py` | CLI orchestration, credential store | | `lighthouse_cli/config.py` | `cookies.json`, `mfa_pending.json` | +| `lighthouse_cli/session_renewal.py` | Unattended sign-in: lock, time-step reservation, breaker | +| `lighthouse_cli/totp.py` | RFC 6238 code generation for the stored authenticator | ## Verification diff --git a/docs/auth-unattended.md b/docs/auth-unattended.md new file mode 100644 index 0000000..95f2d01 --- /dev/null +++ b/docs/auth-unattended.md @@ -0,0 +1,202 @@ +# Unattended sign-in with a stored authenticator + +An opt-in way for an agent or script to keep using `lighthouse` past the +~5-day session limit without a person entering the password and a second +factor. + +## Why it is needed + +- The D2L session lasts about 5 days. +- MAHE's Microsoft sign-in frequency is also about 5 days: even with "Stay + signed in", Microsoft asks for the password and a second factor again. +- So the CLI has to complete MFA itself. It can do that only with an + authenticator app's time-based code (`PhoneAppOTP`), the one MFA path where a + code the server did not send is valid. SMS, voice and push always need a + person. + +## How it works + +- `lighthouse auth totp-setup` seals the secret of an authenticator app + registered on your Microsoft account. It is stored next to your saved + username and password, bound to that username. +- When a read (GET or HEAD, on the built-in Lighthouse connection) finds the + session expired or the cookies missing, the CLI signs in once. It uses the + pure-HTTP flow with `--mfa-method app`, then retries the request once. A + notice goes to stderr; stdout carries only the command's normal output. +- D2L answers some reads of an expired session with 403 ("Permission + denied") instead of 401. On a 403, the CLI asks `whoami` once; the 403 + counts as an expired session only if `whoami` is signed out too. Any other + answer keeps the 403. +- The code is generated only after Microsoft starts the challenge + (`BeginAuth`). It is submitted once and never shown. +- `lighthouse auth login` uses the stored authenticator when you give no + `--totp` and no `--mfa-method` (or `--mfa-method app`), and the username + matches the one the secret is bound to. It prints "Using the stored + authenticator for verification." to stderr and never prompts for a code. +- One sign-in runs at a time. Other `lighthouse` processes wait for it (up to + 2 minutes) and then use the new session instead of signing in again. + +**What never signs in automatically:** + +- uploads and other writes (`submit`, quiz answers, any POST/PUT/DELETE); +- reads marked unsafe to repeat, and the quiz-attempt flows; +- connections other than the built-in Lighthouse one. + +These fail with "Session expired" as before. Run `lighthouse auth login`, or +any read command, to sign in. + +## Setup + +You need an encryption key source (`LIGHTHOUSE_SECRETS_PASSPHRASE` or the OS +keyring; see "Encryption key sources" in the README). + +1. Sign in once and save your username and password: + + ```bash + lighthouse auth login --save-credentials + ``` + +2. Open and choose **Add + sign-in method** → **Authenticator app** → **Set up a different + authentication app**. When the QR code appears, choose **Can't scan + image?** to see the secret key. Keep this page open. +3. Store the secret. Paste it at the hidden prompt: + + ```bash + lighthouse auth totp-setup + ``` + + Or pipe it on stdin, for example from a password manager: + + ```bash + pass show mahe/authenticator | lighthouse auth totp-setup --json + ``` + + It accepts the bare base32 key or an `otpauth://totp/...` URI with SHA1, + 6 digits and a 30-second period. There is no command-line argument for it, + because process listings show arguments. +4. Scan the same QR code with an authenticator app on your phone. That app is + your backup, and it gives you the code Microsoft asks for to finish + registration. The CLI never shows a code. +5. Finish registration on the Microsoft page with the phone app's code. +6. Wait until the phone app shows a new code, then sign in with the stored + authenticator: + + ```bash + lighthouse auth login + ``` + + The first sign-in after setup can wait up to 30 seconds. The CLI never + reuses a time step, and setup reserves the current one. +7. Check that it is configured: + + ```bash + lighthouse auth status --json + # {"valid": true, ..., "unattended_login": {"configured": true, "blocked": false}} + ``` + +If step 6 fails with `code_rejected` while the phone app accepts the same +secret, stop and report it. Do not retry in a loop. The account may have more +than one authenticator-app method, and the CLI cannot choose between them. + +## Commands + +| Command | What it does | +|---------|--------------| +| `lighthouse auth totp-setup [--json]` | Store the authenticator secret (hidden prompt or stdin). Needs saved credentials. Clears the breaker. JSON: `{"success": true, "configured": true}` | +| `lighthouse auth totp-remove [--json]` | Delete the stored secret and the breaker state. Saved credentials and cookies stay. JSON: `{"success": true, "removed": true}` (`false` if nothing was stored) | +| `lighthouse auth login [--json]` | With the stored authenticator configured, signs in with no prompt. Any `--totp`, or `--mfa-method sms\|call\|push\|choose\|auto`, keeps the person-driven flow | +| `lighthouse auth status [--json]` | Signs in again if needed, like any read. Reports `renewed` and `unattended_login: {configured, blocked}` | + +## When automatic sign-in fails + +Failures are recorded in `relogin_state.json`, so a wrong password or secret +cannot cause repeated failed sign-ins on your account. The file holds only a +category and timestamps. Commands then fail with: + +``` +Session expired; automatic sign-in failed (). Run: lighthouse auth login +``` + +| Category | Meaning | Effect | Fix | +|----------|---------|--------|-----| +| `credentials_rejected` | Microsoft rejected the saved username or password | Blocked | See "Password change" below | +| `code_rejected` | Microsoft rejected the generated code | Blocked | Check the clock. If it is right, register the app again and run `auth totp-setup` | +| `method_unavailable` | The account offers no authenticator-app code method | Blocked | Register the app, then `auth totp-setup` | +| `clock_skew` | No unused time step is available (the clock went backwards) | Blocked | Fix the clock, then `auth login` | +| `interrupt` | Microsoft showed a page that needs a person (for example a security-info prompt) | Blocked | Sign in once in a browser, then `auth login` | +| `unknown` | Anything else, including an unreadable state file | Blocked | `auth login --mfa-method choose` | +| `network` | Microsoft or D2L could not be reached | 5-minute wait between automatic attempts | Retry later | +| `busy`, `store`, `pending_human_login` | Another sign-in is running; local files unreadable; a two-step `auth login` is waiting for `auth verify` | Not recorded | Retry; check the key source; finish with `auth verify` | + +**While blocked**, automatic sign-in is refused without contacting Microsoft. +These clear the block: + +- a successful `lighthouse auth login` with the stored authenticator (explicit + login is never refused by the breaker); +- `lighthouse auth totp-setup`; +- `lighthouse auth totp-remove`. + +A login by SMS, call or push gives you a session but leaves the block in place. + +## Clock + +Codes depend on the system clock. Keep it synced with NTP (for example +`timedatectl set-ntp true`, `systemd-timesyncd` or `chrony`). A clock that is +more than about 30 seconds off gives `code_rejected`. A clock that goes back +past the last step used gives `clock_skew`. Both block automatic sign-in until +you fix the clock and sign in. + +## Password change + +A plain `auth login` keeps using the saved password. After changing it, save +the new one without typing it into the command line: + +```bash +read -rsp 'New password: ' LIGHTHOUSE_PASSWORD; echo +LIGHTHOUSE_PASSWORD="$LIGHTHOUSE_PASSWORD" lighthouse auth login --save-credentials +unset LIGHTHOUSE_PASSWORD +``` + +## Security trade-off + +The password and the second factor sit in the same sealed store. Anyone who +can unseal it can sign in to your whole MAHE Microsoft 365 account, not only +Lighthouse. That means anyone with your OS keyring session, or with +`LIGHTHOUSE_SECRETS_PASSPHRASE`. Use this only on a machine you control, and +keep the passphrase out of shell history and shared files. + +The CLI never prints, logs or stores in plain text the secret, a generated +code, the password, the cookies or the username. Each code is submitted once, +and time steps only move forward. + +## Revoking + +1. `lighthouse auth totp-remove` +2. At , delete the + authenticator app you registered for the CLI. +3. If the machine or passphrase may be exposed, change your MAHE password and + delete `credentials.json` and `cookies.json` from the config directory + (`~/.config/lighthouse-cli/`, or `LIGHTHOUSE_CONFIG_DIR`). + +## Out of scope + +- Unattended SMS, voice call or push approval (including Microsoft + Authenticator push). +- Other MAHE sign-in methods: app passwords (legacy protocols only; they cannot + complete the web sign-in), hardware tokens (the secret cannot be extracted), + passkeys (need a person present), alternate or office phone (voice calls), + and email (password reset only). +- Keeping Microsoft's "Stay signed in" cookie between sign-ins. +- Reading mail or using Microsoft Graph. + +## Files + +All in the config directory, mode `0600`: + +| File | Contents | +|------|----------| +| `credentials.json` | Username and password (sealed) | +| `authenticator.json` | Authenticator secret, its username and the last time step used (sealed). Plaintext metadata: kind, algorithm, digits, period | +| `relogin_state.json` | Breaker state: blocked flag, category, timestamps. No secrets | +| `relogin.lock` | Empty lock file that keeps sign-ins one at a time | diff --git a/docs/runbooks/auth-expiry-recovery.md b/docs/runbooks/auth-expiry-recovery.md index 473559c..0c5b956 100644 --- a/docs/runbooks/auth-expiry-recovery.md +++ b/docs/runbooks/auth-expiry-recovery.md @@ -6,6 +6,11 @@ auth-related errors after previously working. ## Symptoms - Any command exits 1 with a session/auth error. +- A read that used to work fails with "Permission denied (HTTP 403)". D2L + answers some reads of an expired session with 403. Reads on the built-in + connection check `whoami` and report an expired session instead; dry-run + (`read_only_auth`) callers and writes still show the 403, so confirm with + `lighthouse auth status --json`. - `lighthouse auth status --json` reports missing or stale cookies (`d2lSecureSessionVal` and friends). @@ -16,6 +21,14 @@ lighthouse auth status --json # confirm state (never prints cookie values) lighthouse auth login --json # fresh Microsoft SSO flow ``` +- With a stored authenticator (`"unattended_login": {"configured": true}`), + reads sign in again by themselves and `auth login` needs no prompt. If they + fail with "automatic sign-in failed ()", fix the cause for that + category, then run `lighthouse auth login` to clear the block. The category + table is in [auth-unattended.md](../auth-unattended.md#when-automatic-sign-in-fails). + Do not loop on `code_rejected` or `credentials_rejected`: repeated failed + sign-ins can lock the account. + - If MFA is server-sent (SMS/WhatsApp): `auth login` returns a pending checkpoint; finish with `lighthouse auth verify --json`, passing the code you received as the positional argument. A literal `--totp` only works for offline `PhoneAppOTP` diff --git a/lighthouse_cli/api.py b/lighthouse_cli/api.py index b2f64c4..db25841 100644 --- a/lighthouse_cli/api.py +++ b/lighthouse_cli/api.py @@ -25,11 +25,20 @@ API_LE, BASE_URL, COOKIE_NAMES, + cookie_file_is_legacy, d2l_cookies_from_entries, load_cookies, missing_cookie_names, save_cookies, ) +from .session_renewal import ( + RenewalError, + RenewalUnavailableError, + SessionProbe, + renew_session, + renewal_lock, + unattended_configured, +) from .utils import _sanitize_filename, get_enrolled_course_catalog # CDP port for browser-harness @@ -45,6 +54,11 @@ _MAX_DOWNLOAD_BYTES_ENV = "LIGHTHOUSE_MAX_DOWNLOAD_BYTES" CDP_RESPONSE_TIMEOUT_SECONDS = 15 +# whoami answers 403 (not 401) when the cookies are signed out. +_WHOAMI_PATH = "/d2l/api/lp/1.47/users/whoami" +_EXPIRED_STATUSES = frozenset({401}) +_WHOAMI_EXPIRED_STATUSES = frozenset({401, 403}) + # --------------------------------------------------------------------------- # Exceptions @@ -399,6 +413,10 @@ def __init__(self, read_only_auth: bool = False) -> None: # Only the built-in Lighthouse connection object may refresh or # migrate auth; any override is read-only, even one naming Lighthouse. self._read_only_auth = bool(read_only_auth) or self.connection is not LIGHTHOUSE + # Set by automatic sign-in: whether this client signed in again, and + # the failure category when it could not. + self.renewed_session = False + self.renewal_failure: str | None = None # -- cookie management -------------------------------------------------- @@ -412,18 +430,41 @@ def _apply_cookies_to_session(self, cookies: dict[str, str]) -> None: path="/", ) + def _install_cookies(self, cookies: dict[str, str]) -> None: + """Use a fresh cookie set from now on (after a sign-in or refresh).""" + self._cookies = dict(cookies) + self._csrf_token = None + self._apply_cookies_to_session(self._cookies) + self._loaded = True + def _ensure_cookies(self) -> dict[str, str]: """Load cookies from disk on first use.""" if not self._loaded: - self._cookies = ( - load_cookies(read_only=True, config_dir=self.connection.cookie_dir, expected_origin=self.base_url) - if self.connection.cookie_dir is not None - else load_cookies(read_only=self._read_only_auth) - ) - self._apply_cookies_to_session(self._cookies) - self._loaded = True + if self.connection.cookie_dir is not None: + cookies = load_cookies( + read_only=True, config_dir=self.connection.cookie_dir, expected_origin=self.base_url + ) + elif not self._read_only_auth and cookie_file_is_legacy(): + cookies = self._load_migrating_legacy_cookies() + else: + cookies = load_cookies(read_only=self._read_only_auth) + self._install_cookies(cookies) return self._cookies + @staticmethod + def _load_migrating_legacy_cookies() -> dict[str, str]: + """Re-seal legacy plaintext cookies under the sign-in lock. + + The document is reread under the lock, so cookies a renewal sealed in + the meantime are returned instead of being overwritten. + """ + try: + with renewal_lock(): + return load_cookies() + except RenewalError as exc: + detail = "cookie upgrade busy" if exc.category == "busy" else "cookie upgrade failed" + raise _session_expired(detail) from None + @property def cookies(self) -> dict[str, str]: return self._ensure_cookies() @@ -442,8 +483,13 @@ def cookies(self) -> dict[str, str]: # D2L normally returns a small number of pages, but a broken ``Next`` # chain must not make a command loop or issue unbounded requests. _MAX_PAGINATION_PAGES = 100 + # The whoami check behind a 403 is a single attempt with a short timeout. + _SIGNED_OUT_CHECK_TIMEOUT = 15 - def _request(self, method: str, url: str, _skip_raise: bool = False, _timeout: int = 30, _replay_safe: bool = True, **kwargs: Any) -> requests.Response: + def _request( + self, method: str, url: str, _skip_raise: bool = False, _timeout: int = 30, _replay_safe: bool = True, + _expired_statuses: frozenset[int] = _EXPIRED_STATUSES, **kwargs: Any, + ) -> requests.Response: """Make an authenticated request with safe retry and auto-refresh. GET and HEAD requests retry HTTP 429 (Too Many Requests) with @@ -453,10 +499,14 @@ def _request(self, method: str, url: str, _skip_raise: bool = False, _timeout: i sent exactly once because replaying a non-idempotent request could duplicate a remote side effect. - On SessionExpiredError, GET and HEAD attempt one auto-refresh via CDP - if a browser with valid cookies is running, then retry the request - once. Non-idempotent methods surface the failure without refreshing - or replaying the request. + On SessionExpiredError, GET and HEAD recover once, then retry the + request once: with the stored authenticator when unattended sign-in + is configured, otherwise via CDP if a browser with valid cookies is + running. D2L answers some reads of an expired session with 403, so + a 403 counts as expiry when whoami also reports the session signed + out. Missing cookies recover the same way, but only through the + stored authenticator. Non-idempotent methods surface the failure + without refreshing or replaying the request. Args: _skip_raise: If True, skip raise_for_status() and return the raw @@ -464,44 +514,106 @@ def _request(self, method: str, url: str, _skip_raise: bool = False, _timeout: i _timeout: Request timeout in seconds (default 30). _replay_safe: False for legacy GET routes that create state, such as preview start. Disables retries and automatic auth replay. + _expired_statuses: Statuses that mean the session is signed out. """ - if missing_cookie_names(self.cookies): - raise _session_expired("no cookies found") - retryable = method.upper() in self._RETRYABLE_METHODS and _replay_safe + # A POST/PUT/etc. may already have been accepted by the server even + # when its response reports an expired session. Never refresh and + # send that body again. The read-only auth mode also forbids + # sign-in, browser refresh and cookie persistence for GET/HEAD. + can_recover = retryable and not self._read_only_auth refresh_attempted = False + if missing_cookie_names(self.cookies): + if not (can_recover and unattended_configured()): + raise _session_expired("no cookies found") + refresh_attempted = True + self._recover_session(browser_fallback=False) while True: try: - return self._do_request(method, url, _skip_raise, _timeout, _replay_safe=_replay_safe, **kwargs) + return self._do_request( + method, url, _skip_raise, _timeout, _replay_safe=_replay_safe, + _expired_statuses=_expired_statuses, **kwargs, + ) except SessionExpiredError: - if self._read_only_auth or not retryable: - # A POST/PUT/etc. may already have been accepted by the - # server even when its response reports an expired - # session. Never refresh and send that body again. The - # read-only auth mode also forbids browser refresh and - # cookie persistence for GET/HEAD requests. + if not can_recover: raise - if refresh_attempted: - raise _session_expired("auto-refresh already attempted") from None - refresh_attempted = True - print("Session expired. Refreshing from browser...", file=sys.stderr) - try: - new_cookies = refresh_auth_from_browser() - except Exception: - raise _session_expired("auto-refresh failed") from None + except requests.HTTPError as exc: + status = getattr(exc.response, "status_code", None) + if not (can_recover and status == 403 and self._whoami_signed_out()): + raise + if refresh_attempted: + raise _session_expired("auto-refresh already attempted") + refresh_attempted = True + self._recover_session(browser_fallback=True) + + def _whoami_signed_out(self) -> bool: + """Whether whoami positively reports the current cookies as signed out. + + Tells an expired session's 403 apart from a genuine permission + denial. ``_do_request`` is called directly, so this check never + signs in or nests, and it is sent once, without retries. Only a + 401, 403 or login redirect counts; any other answer or failure keeps + the caller's 403. + """ + try: + resp = self._do_request( + "GET", f"{self.base_url}{_WHOAMI_PATH}", False, self._SIGNED_OUT_CHECK_TIMEOUT, + _replay_safe=False, _expired_statuses=_WHOAMI_EXPIRED_STATUSES, + ) + except SessionExpiredError: + return True + except (requests.RequestException, NetworkError): + return False + _close_response(resp) + return False + + def _recover_session(self, *, browser_fallback: bool) -> None: + """Replace expired cookies: stored authenticator first, then CDP.""" + if unattended_configured(): + print("Session expired. Signing in again with the stored authenticator...", file=sys.stderr) + try: + result = renew_session( + observed_cookies=dict(self._cookies), validate=probe_cookies, automatic=True + ) + except RenewalUnavailableError: + pass + except RenewalError as exc: + self.renewal_failure = exc.category + raise _session_expired(f"automatic sign-in failed: {exc.category}") from None + else: + self.renewed_session = self.renewed_session or result.signed_in + self._install_cookies(result.cookies) + return + if not browser_fallback: + raise _session_expired("no cookies found") + self._refresh_from_browser() - if missing := missing_cookie_names(new_cookies): - raise _session_expired(f"CDP cookies missing: {missing}") from None + def _refresh_from_browser(self) -> None: + """Copy a signed-in browser's cookies through CDP and save them.""" + print("Session expired. Refreshing from browser...", file=sys.stderr) + try: + new_cookies = refresh_auth_from_browser() + except Exception: + raise _session_expired("auto-refresh failed") from None + + if missing := missing_cookie_names(new_cookies): + raise _session_expired(f"CDP cookies missing: {missing}") from None + # Extraction stays outside the lock; only the write is serialized + # with automatic sign-in. + try: + with renewal_lock(): save_cookies(new_cookies) - self._cookies = new_cookies - self._csrf_token = None - self._apply_cookies_to_session(new_cookies) + except RenewalError as exc: + detail = "auto-refresh busy" if exc.category == "busy" else "auto-refresh failed" + raise _session_expired(detail) from None + self._install_cookies(new_cookies) def _do_request( self, method: str, url: str, # skip_raise forwarded from _request._skip_raise - skip_raise: bool, timeout: int, _replay_safe: bool = True, **kwargs: Any, + skip_raise: bool, timeout: int, _replay_safe: bool = True, + _expired_statuses: frozenset[int] = _EXPIRED_STATUSES, **kwargs: Any, ) -> requests.Response: """Execute the HTTP request with bounded idempotent retries. @@ -547,8 +659,9 @@ def _do_request( try: _raise_for_redirect(resp, allow_other=skip_raise) - if resp.status_code == 401: - raise _session_expired("HTTP 401 Unauthorized") + if resp.status_code in _expired_statuses: + reason = "Unauthorized" if resp.status_code == 401 else "Forbidden" + raise _session_expired(f"HTTP {resp.status_code} {reason}") if not skip_raise: _check_status(resp) except Exception: @@ -964,12 +1077,42 @@ def get_topic_html(self, org_unit_id: int, topic_id: int) -> tuple[bytes, str]: sanitized = sanitized + ".html" return content_bytes, sanitized + def _whoami_signed_in(self) -> bool: + """Whether whoami names an account for these cookies. + + The body is checked for a positive ``Identifier`` and then dropped. + A server error or transport failure raises. + """ + try: + resp = self._request("GET", f"{self.base_url}{_WHOAMI_PATH}", _expired_statuses=_WHOAMI_EXPIRED_STATUSES) + except SessionExpiredError: + return False + except requests.HTTPError as exc: + status = getattr(exc.response, "status_code", None) + if isinstance(status, int) and (status >= 500 or status == 429): + raise + return False + try: + body = resp.json() + except ValueError: + return False + finally: + _close_response(resp) + identifier = body.get("Identifier") if isinstance(body, dict) else None + return _positive_org_unit_id(identifier) is not None + + def probe_session(self) -> SessionProbe: + """``valid``, ``invalid`` (signed out), or ``unavailable`` (D2L did not answer).""" + try: + return "valid" if self._whoami_signed_in() else "invalid" + except (NetworkError, requests.RequestException): + return "unavailable" + def check_auth(self) -> bool: - """Quick auth check via /d2l/api/versions/.""" + """Whether the session is signed in. Transport failures still raise.""" try: - self.get_json(f"{self.base_url}/d2l/api/versions/") - return True - except (SessionExpiredError, requests.HTTPError): + return self._whoami_signed_in() + except requests.HTTPError: return False # -- Dropbox / Assignments ---------------------------------------------- @@ -1162,6 +1305,20 @@ def _extract_filename(headers: dict[str, str]) -> str: return extended_filename or filename +def probe_cookies(cookies: dict[str, str]) -> SessionProbe: + """Probe a candidate cookie set without reading or writing ``cookies.json``. + + The client is read-only, so a signed-out answer never triggers another + sign-in. + """ + client = LighthouseClient(read_only_auth=True) + client._install_cookies(cookies) + try: + return client.probe_session() + finally: + client._session.close() + + def resolve_course_id(client: LighthouseClient, identifier: str) -> int: """Resolve a course identifier (int org-unit-id or partial name) to an int id. diff --git a/lighthouse_cli/auth.py b/lighthouse_cli/auth.py index 8562f9f..e362346 100644 --- a/lighthouse_cli/auth.py +++ b/lighthouse_cli/auth.py @@ -9,7 +9,9 @@ planning (``resume`` | ``fresh`` | ``defer``); - the ``auth login`` / ``auth verify`` entry points, which share one persist→check→report success tail (seal cookies → validate session → only - then optionally store credentials; verify never stores credentials). + then optionally store credentials; verify never stores credentials); +- ``auth totp-setup`` / ``auth totp-remove`` and the stored-authenticator + login, which sign in through ``lighthouse_cli.session_renewal``. """ from __future__ import annotations @@ -20,12 +22,13 @@ import os import re import sys +import warnings from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass, field from typing import Any -from .api import LighthouseClient, NetworkError, refresh_auth_from_browser +from .api import NetworkError, probe_cookies, refresh_auth_from_browser from .config import ( COOKIE_NAMES, clear_mfa_pending, @@ -34,7 +37,7 @@ missing_cookie_names, save_cookies, ) -from .credential_store import CredentialStore, CredentialStoreError +from .credential_store import _INVALID_TOTP_MSG, CredentialStore, CredentialStoreError from .ms_auth import MicrosoftSSOClient from .ms_errors import ( MFA_METHOD_APP, @@ -49,6 +52,15 @@ MicrosoftSSOError, ) from .ms_mfa import format_user_proof, safe_auth_method_id +from .session_renewal import ( + RenewalError, + RenewalUnavailableError, + remove_authenticator, + renew_session, + renewal_lock, + store_authenticator, +) +from .totp import SECRET_ERROR_MESSAGES, TotpSecretError, parse_totp_secret # --------------------------------------------------------------------------- # Exceptions and uniform exits @@ -59,6 +71,10 @@ class _PromptUnavailableError(Exception): """A credential is missing and stdin cannot be prompted.""" +class _HiddenInputUnavailableError(Exception): + """The terminal cannot turn echo off for a secret prompt.""" + + _AUTH_ERROR_FALLBACK = "Authentication failed. Check your credentials and try again." _EMAIL_RE = re.compile(r"(?i)\b[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+\b") _PHONE_RE = re.compile(r"(?" + ), + "unknown": ( + "Sign-in with the stored authenticator failed unexpectedly. " + "Run: lighthouse auth login --mfa-method choose" + ), +} +# First-party fixed messages shown verbatim: the generic filter below would +# drop their recovery commands. +_SAFE_FIXED_AUTH_MESSAGES = frozenset({ + _SIGN_IN_STORE, + _NO_SAVED_CREDENTIALS, + _HIDDEN_INPUT_UNAVAILABLE, + _NO_MATCHING_AUTHENTICATOR, + _INVALID_TOTP_MSG, + *_STORED_LOGIN_FAILURES.values(), + *SECRET_ERROR_MESSAGES, +}) _SAFE_FIRST_PARTY_AUTH_PREFIXES = ( ("2fa verification timed out", "2FA verification timed out waiting for approval."), ("d2l acs redirect limit exceeded", "D2L ACS redirect limit exceeded."), @@ -232,7 +306,7 @@ def _safe_auth_error_message(msg: object) -> str: if not isinstance(msg, str) or not msg: return _AUTH_ERROR_FALLBACK text = " ".join(msg.split()) - if text in _SAFE_BROWSER_NETWORK_ERRORS: + if text in _SAFE_BROWSER_NETWORK_ERRORS or text in _SAFE_FIXED_AUTH_MESSAGES: return text lowered = text.casefold() for prefix, safe_message in _SAFE_FIRST_PARTY_AUTH_PREFIXES: @@ -494,6 +568,19 @@ def plan_login( return LoginPlan(mode, totp_code, read_totp_after_challenge, defer_mfa_to_pending) +def use_stored_authenticator( + *, explicit_method: str | None, totp_code: str | None, totp_stdin: bool +) -> bool: + """Whether ``auth login`` may answer MFA with the stored authenticator. + + Only when no method was requested (flag or ``LIGHTHOUSE_MFA_METHOD``) or + ``app`` was, and no code was supplied. + """ + if totp_code is not None or totp_stdin: + return False + return explicit_method in (None, MFA_METHOD_APP) + + def _resolve_command_credentials( username: str | None, password: str | None, *, interactive: bool, json_output: bool ) -> tuple[str | None, str | None]: @@ -558,6 +645,11 @@ def _print_login_next_steps() -> None: _print_command_guide() +def _lock_failure_message(exc: RenewalError) -> str: + """Fixed message for a sign-in lock that could not be taken.""" + return _SIGN_IN_BUSY if exc.category == "busy" else _SIGN_IN_STORE + + def _persist_check_report( cookies: dict[str, str], *, @@ -574,26 +666,51 @@ def _persist_check_report( session is validated second, and only after a verified session may the username/password pair be stored. Verify passes no pair — it NEVER saves credentials. - """ - save_cookies(cookies) + The sequence holds the sign-in lock, so an automatic sign-in cannot + publish cookies in between, and it checks these cookies themselves rather + than whatever ``cookies.json`` holds by then. + """ hint = f" {failure_hint}" if failure_hint else "" - if not LighthouseClient(read_only_auth=True).check_auth(): - return _auth_error( - f"Login completed but session verification failed.{hint}", - json_output, - ) + try: + with renewal_lock(): + save_cookies(cookies) + if probe_cookies(cookies) != "valid": + return _auth_error( + f"Login completed but session verification failed.{hint}", + json_output, + ) + + if save_credentials_pair is not None: + try: + CredentialStore().save(*save_credentials_pair) + except CredentialStoreError as exc: + print( + "Warning: Could not save credentials: " + f"{_safe_auth_error_message(str(exc))}", + file=sys.stderr, + ) + except RenewalError as exc: + return _auth_error(_lock_failure_message(exc), json_output) + + return _report_login_success( + cookies, + json_output=json_output, + success_message=success_message, + include_cookie_names=include_cookie_names, + show_next_steps=show_next_steps, + ) - if save_credentials_pair is not None: - try: - CredentialStore().save(*save_credentials_pair) - except CredentialStoreError as exc: - print( - "Warning: Could not save credentials: " - f"{_safe_auth_error_message(str(exc))}", - file=sys.stderr, - ) +def _report_login_success( + cookies: dict[str, str], + *, + json_output: bool, + success_message: str = "Login complete. Session saved and verified.", + include_cookie_names: bool = True, + show_next_steps: bool = False, +) -> int: + """Report a saved, verified session (cookie names only, never values).""" reported_cookie_names = [name for name in COOKIE_NAMES if name in cookies] if json_output: print(json.dumps({"success": True, "cookies": reported_cookie_names})) @@ -863,6 +980,24 @@ def cmd_auth_login( # been read earlier, but only under error suppression.) CredentialStore().preflight() + # A stored authenticator answers the code itself. An explicit method + # other than ``app``, or any --totp, keeps the person-driven flow. + if ( + use_stored_authenticator( + explicit_method=resolved_mfa_method if configured_mfa_method else None, + totp_code=totp_code, + totp_stdin=totp_stdin, + ) + and _stored_authenticator_matches(username) + ): + return _login_with_stored_authenticator( + username, + password, + save_credentials=save_credentials, + json_output=json_output, + show_next_steps=interactive and not json_output, + ) + # Resume the pending MFA session only when the provided code belongs to it. # An explicit --mfa-method that differs from the pending session (e.g. an # offline app TOTP after a stale SMS pending) starts a fresh flow instead. @@ -953,3 +1088,134 @@ def _on_password_accepted() -> None: include_cookie_names=False, show_next_steps=interactive and not json_output, ) + + +# --------------------------------------------------------------------------- +# Stored authenticator (unattended sign-in) +# --------------------------------------------------------------------------- + +_MAX_SECRET_LINE = 4096 + + +def _stored_authenticator_matches(username: str) -> bool: + """Whether a readable stored authenticator is bound to ``username``.""" + try: + stored = CredentialStore().load_totp() + except CredentialStoreError as exc: + print( + "Warning: ignoring an unreadable stored authenticator (" + f"{_safe_auth_error_message(str(exc))}).", + file=sys.stderr, + ) + return False + return stored is not None and stored.username.casefold() == username.casefold() + + +def _login_with_stored_authenticator( + username: str, + password: str, + *, + save_credentials: bool, + json_output: bool, + show_next_steps: bool, +) -> int: + """Sign in with the stored authenticator; cookies are saved and checked inside.""" + print("Using the stored authenticator for verification.", file=sys.stderr, flush=True) + try: + result = renew_session( + observed_cookies={}, + validate=probe_cookies, + automatic=False, + credentials=(username, password), + persist_credentials=save_credentials, + ) + except RenewalUnavailableError: + return _auth_error(_NO_MATCHING_AUTHENTICATOR, json_output) + except RenewalError as exc: + return _auth_error( + _STORED_LOGIN_FAILURES.get(exc.category, _STORED_LOGIN_FAILURES["unknown"]), + json_output, + ) + + # This sign-in supersedes any deferred two-step challenge, which would + # otherwise keep blocking automatic sign-in. + clear_mfa_pending() + return _report_login_success( + result.cookies, + json_output=json_output, + include_cookie_names=False, + show_next_steps=show_next_steps, + ) + + +def _read_totp_secret() -> str: + """Read the secret: a hidden prompt on a terminal, else one stdin line. + + ``getpass`` falls back to echoed input with only a warning when it cannot + turn echo off; that warning is raised as an error before anything is read. + """ + if not _is_interactive(): + return sys.stdin.readline(_MAX_SECRET_LINE) + print( + "Paste the authenticator secret key or otpauth:// URI (input is hidden).", + file=sys.stderr, + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", getpass.GetPassWarning) + try: + return getpass.getpass("Secret: ", stream=sys.stderr) + except getpass.GetPassWarning: + raise _HiddenInputUnavailableError from None + except EOFError: + return "" + + +@_clean_auth_command +def cmd_auth_totp_setup(*, json_output: bool = False) -> int: + """Store the authenticator secret for unattended sign-in. + + The secret is bound to the saved username and sealed like the password. + It is never printed, and neither is a code generated from it. + """ + ensure_config_dir() + store = CredentialStore() + store.preflight() + credentials = store.load() + if credentials is None: + return _auth_error(_NO_SAVED_CREDENTIALS, json_output) + + try: + params = parse_totp_secret(_read_totp_secret()) + except _HiddenInputUnavailableError: + return _auth_error(_HIDDEN_INPUT_UNAVAILABLE, json_output) + except TotpSecretError as exc: + return _auth_error(str(exc), json_output) + + try: + store_authenticator(credentials[0], params) + except RenewalError as exc: + return _auth_error(_lock_failure_message(exc), json_output) + + if json_output: + print(json.dumps({"success": True, "configured": True})) + else: + print("Authenticator secret stored. Next: lighthouse auth login") + return 0 + + +@_clean_auth_command +def cmd_auth_totp_remove(*, json_output: bool = False) -> int: + """Delete the stored authenticator; saved credentials and cookies stay.""" + ensure_config_dir() + try: + removed = remove_authenticator() + except RenewalError as exc: + return _auth_error(_lock_failure_message(exc), json_output) + + if json_output: + print(json.dumps({"success": True, "removed": removed})) + elif removed: + print("Stored authenticator removed.") + else: + print("No stored authenticator was configured.") + return 0 diff --git a/lighthouse_cli/cli.py b/lighthouse_cli/cli.py index f95fcbb..cd65a46 100644 --- a/lighthouse_cli/cli.py +++ b/lighthouse_cli/cli.py @@ -31,6 +31,8 @@ def invoke(*args: Any, **kwargs: Any) -> int: cmd_auth_login = _lazy_command(".auth", "cmd_auth_login") cmd_auth_mfa_methods = _lazy_command(".auth", "cmd_auth_mfa_methods") cmd_auth_refresh = _lazy_command(".auth", "cmd_auth_refresh") +cmd_auth_totp_remove = _lazy_command(".auth", "cmd_auth_totp_remove") +cmd_auth_totp_setup = _lazy_command(".auth", "cmd_auth_totp_setup") cmd_auth_verify = _lazy_command(".auth", "cmd_auth_verify") cmd_auth_status = _lazy_command(".commands", "cmd_auth_status") cmd_content = _lazy_command(".commands", "cmd_content") @@ -205,6 +207,36 @@ def auth_mfa_methods(username: str | None, json_output: bool) -> None: raise SystemExit(cmd_auth_mfa_methods(username=username, password=None, json_output=json_output)) +@auth.command("totp-setup", cls=JsonOutputCommand) +@click.option("--json", "json_output", is_flag=True, help="Output this command's JSON result.") +def auth_totp_setup(json_output: bool) -> None: + """Store an authenticator-app secret so expired sessions sign in again unattended. + + First save credentials (lighthouse auth login --save-credentials). Then, in + Microsoft security info, add a sign-in method: Authenticator app, "Set up a + different authentication app", "Can't scan image?". Enter the secret key + here; input is hidden in a terminal, or pipe it on stdin. The secret is + never accepted as a command-line argument. + + Trade-off: the password and the second factor then sit on this machine, + sealed with the same key source (LIGHTHOUSE_SECRETS_PASSPHRASE or the OS + keyring). Anyone who can unseal them can sign in as you. Undo with + lighthouse auth totp-remove and remove the app from Microsoft security info. + """ + raise SystemExit(cmd_auth_totp_setup(json_output=json_output)) + + +@auth.command("totp-remove", cls=JsonOutputCommand) +@click.option("--json", "json_output", is_flag=True, help="Output this command's JSON result.") +def auth_totp_remove(json_output: bool) -> None: + """Delete the stored authenticator secret. + + Saved credentials and cookies stay. Also remove the authenticator app from + Microsoft security info. + """ + raise SystemExit(cmd_auth_totp_remove(json_output=json_output)) + + # --------------------------------------------------------------------------- # Config subgroup # --------------------------------------------------------------------------- diff --git a/lighthouse_cli/commands.py b/lighthouse_cli/commands.py index d278543..351dbeb 100644 --- a/lighthouse_cli/commands.py +++ b/lighthouse_cli/commands.py @@ -24,6 +24,7 @@ from .display import utc_now_iso as _utc_now_iso from .manifest import MAX_MANIFEST_SIZE, normalize_sha256 from .quiz_rules import navigation_rules +from .session_renewal import unattended_status from .sync_engine import Mode, run_course, safe_output_path_text, validate_output_root from .utils import _course_identifier, get_enrolled_course_catalog @@ -1032,34 +1033,62 @@ def _resolve_course_scope( # --------------------------------------------------------------------------- def cmd_auth_status(json_output: bool = False) -> int: - """Check if stored cookies are valid.""" - payload = {"valid": False} + """Check if stored cookies are valid. + + An expired (or missing) session signs in again when unattended sign-in is + configured, exactly as any other command would. + """ + unattended = unattended_status() + payload: dict[str, Any] = {"valid": False, "unattended_login": unattended} try: client = LighthouseClient() cookies = client.cookies except Exception as e: return _error(e, json_output=json_output, payload=payload) - if not cookies: + if not cookies and not unattended["configured"]: return _error("No cookies found. Run: lighthouse auth login", json_output=json_output, payload=payload) try: valid = client.check_auth() except Exception as e: - return _error(e, json_output=json_output, payload=payload) + return _error(e, json_output=json_output, payload=_status_payload(payload, client)) + cookies = client.cookies if valid: if json_output: - _output_json({"valid": True, "cookies": list(cookies.keys())}) + _output_json({ + "valid": True, + "cookies": list(cookies.keys()), + "renewed": client.renewed_session, + "unattended_login": unattended_status(), + }) return 0 + if client.renewed_session: + print("Session renewed.") print(f"Session valid. Cookies: {', '.join(cookies.keys())}") warn_if_cookies_stale() return 0 + message = ( + f"Session expired (automatic sign-in failed: {client.renewal_failure})." + if client.renewal_failure + else "Session expired. Run: lighthouse auth login" + ) return _error( - "Session expired. Run: lighthouse auth login", + message, json_output=json_output, - payload={"valid": False, "cookies": list(cookies.keys())}, + payload=_status_payload({**payload, "cookies": list(cookies.keys())}, client), ) +def _status_payload(payload: dict[str, Any], client: LighthouseClient) -> dict[str, Any]: + """Add the renewal outcome (a fixed category or null) to a failure payload.""" + return { + **payload, + "renewed": client.renewed_session, + "category": client.renewal_failure, + "unattended_login": unattended_status(), + } + + def cmd_semesters(json_output: bool = False) -> int: """List all semesters.""" try: diff --git a/lighthouse_cli/config.py b/lighthouse_cli/config.py index 5e44762..defe902 100644 --- a/lighthouse_cli/config.py +++ b/lighthouse_cli/config.py @@ -247,6 +247,18 @@ def save_cookies(cookies: dict[str, str], *, extracted_at: str | None = None) -> ) +def cookie_file_is_legacy(config_dir: Path | None = None) -> bool: + """True when ``cookies.json`` holds a legacy plaintext document. + + A read-only peek: callers that may migrate the file take the sign-in lock + first, and a sealed or absent file needs no lock because loading it never + writes. + """ + store = CredentialStore(config_dir=config_dir) if config_dir is not None else CredentialStore() + doc = _read_cookie_document(store) + return doc is not None and not is_sealed_document(doc) + + def get_cookie_age_days() -> float | None: """Return the age of stored cookies in days, or None if unavailable.""" data = _read_cookie_document(CredentialStore()) diff --git a/lighthouse_cli/credential_store.py b/lighthouse_cli/credential_store.py index ad462be..54caa19 100644 --- a/lighthouse_cli/credential_store.py +++ b/lighthouse_cli/credential_store.py @@ -1,8 +1,8 @@ """Encrypted storage for every lighthouse-cli session secret. Single owner of sealing/unsealing for ``credentials.json``, ``cookies.json``, -and the MFA pending checkpoint. Imports nothing from ``auth``, ``ms_auth``, or -``config`` — no import cycles. +``authenticator.json`` and the MFA pending checkpoint. Imports nothing from +``auth``, ``ms_auth``, or ``config`` — no import cycles. Key resolution (in order): @@ -39,6 +39,7 @@ import json import os from contextlib import suppress +from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path from typing import Any @@ -47,6 +48,14 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from lighthouse_cli.totp import ( + TOTP_ALGORITHM, + TOTP_DIGITS, + TOTP_PERIOD, + TotpParams, + TotpSecretError, + parse_totp_secret, +) from lighthouse_cli.utils import _loads_strict_json, atomic_write SERVICE_NAME = "lighthouse-cli" @@ -70,6 +79,18 @@ # fixed message keeps this storage boundary safe for CLI diagnostics. _UNTRUSTED_PATH_MSG = "Credential storage path contains a symlink and cannot be used." +#: Plaintext metadata of ``authenticator.json``; anything else is rejected. +_TOTP_METADATA: dict[str, Any] = { + "kind": "totp", + "algorithm": TOTP_ALGORITHM, + "digits": TOTP_DIGITS, + "period": TOTP_PERIOD, +} +_TOTP_SECRET_KEYS = frozenset({"username", "secret", "last_step"}) +_INVALID_TOTP_MSG = ( + "Stored authenticator data is invalid. Run: lighthouse auth totp-setup" +) + class CredentialStoreError(Exception): """Raised when key resolution, sealing, or unsealing fails. @@ -252,6 +273,8 @@ class CredentialStore: - ``credentials.json`` — stored username/password - ``cookies.json`` — D2L session cookies - ``mfa_pending.json`` — in-progress MFA checkpoint + - ``authenticator.json`` — opt-in authenticator (TOTP) secret, the + username it belongs to, and the last time step used The config directory is resolved from ``LIGHTHOUSE_CONFIG_DIR`` at construction time. @@ -264,6 +287,7 @@ def __init__(self, *, config_dir: Path | None = None) -> None: self.credentials_file = self.config_dir / "credentials.json" self.cookie_file = self.config_dir / "cookies.json" self.mfa_pending_file = self.config_dir / "mfa_pending.json" + self.authenticator_file = self.config_dir / "authenticator.json" # -- preflight ------------------------------------------------------------ @@ -419,6 +443,74 @@ def load(self) -> tuple[str, str] | None: ) from None return _credential_pair(data) + # -- stored authenticator ---------------------------------------------------- + + def save_totp( + self, username: str, params: TotpParams, *, last_step: int | None + ) -> None: + """Seal the authenticator secret bound to ``username``. + + ``last_step`` is the newest RFC 6238 time step already used (or + None); codes are only ever generated for later steps. + """ + if not isinstance(username, str) or not username.strip(): + raise CredentialStoreError("Username cannot be empty") + if not isinstance(params, TotpParams) or not _valid_last_step(last_step): + raise CredentialStoreError(_INVALID_TOTP_MSG) + if (params.digits, params.period) != (TOTP_DIGITS, TOTP_PERIOD): + raise CredentialStoreError(_INVALID_TOTP_MSG) + self.write_artifact( + self.authenticator_file, + metadata=dict(_TOTP_METADATA), + secret={ + "username": username, + "secret": params.secret_base32, + "last_step": last_step, + }, + ) + + def load_totp(self) -> StoredTotp | None: + """Load the stored authenticator, or None when none is configured. + + Raises: + CredentialStoreError: When the file cannot be opened, or with a + fixed message when any field fails strict validation. + """ + artifact = self.read_artifact(self.authenticator_file) + if artifact is None: + return None + metadata, secret = artifact + return _stored_totp(metadata, secret) + + def reserve_totp_step(self, step: int) -> None: + """Record ``step`` as used before a code for it is generated. + + Steps strictly advance; a step at or below the stored one is refused. + """ + stored = self.load_totp() + if stored is None or not _valid_last_step(step) or step is None: + raise CredentialStoreError(_INVALID_TOTP_MSG) + if stored.last_step is not None and step <= stored.last_step: + raise CredentialStoreError( + "The authenticator time step was already used." + ) + self.save_totp(stored.username, stored.params, last_step=step) + + def delete_totp(self) -> bool: + """Remove ``authenticator.json``; return whether a file was removed.""" + _validate_credential_path(self.config_dir) + _validate_credential_path(self.authenticator_file) + try: + self.authenticator_file.unlink() + except FileNotFoundError: + return False + except OSError as exc: + raise CredentialStoreError( + f"{self.authenticator_file.name} could not be removed " + f"({exc.__class__.__name__})." + ) from None + return True + def _load_and_migrate_legacy(self, blob: bytes) -> tuple[str, str]: """Decrypt legacy raw-Fernet credentials with the keyring key, then re-seal them as a v2 envelope recording the keyring source. @@ -594,6 +686,45 @@ def _credential_pair(data: object) -> tuple[str, str]: return username, password +@dataclass(frozen=True) +class StoredTotp: + """A loaded authenticator binding. ``repr`` shows only ``last_step``.""" + + username: str = field(repr=False) + params: TotpParams = field(repr=False) + last_step: int | None + + +def _valid_last_step(value: object) -> bool: + """None, or a non-negative int that is not a bool.""" + return value is None or (type(value) is int and value >= 0) + + +def _stored_totp(metadata: dict[str, Any], secret: dict[str, Any]) -> StoredTotp: + """Strictly validate a decrypted authenticator document.""" + if metadata.keys() != _TOTP_METADATA.keys() or any( + type(metadata[key]) is not type(value) or metadata[key] != value + for key, value in _TOTP_METADATA.items() + ): + raise CredentialStoreError(_INVALID_TOTP_MSG) + username, encoded, last_step = ( + secret.get("username"), secret.get("secret"), secret.get("last_step"), + ) + if ( + secret.keys() != _TOTP_SECRET_KEYS + or not isinstance(username, str) + or not username.strip() + or not isinstance(encoded, str) + or not _valid_last_step(last_step) + ): + raise CredentialStoreError(_INVALID_TOTP_MSG) + try: + params = parse_totp_secret(encoded) + except TotpSecretError: + raise CredentialStoreError(_INVALID_TOTP_MSG) from None + return StoredTotp(username=username, params=params, last_step=last_step) + + def is_sealed_document(data: object) -> bool: """True when ``data`` looks like a v2 sealed document.""" return ( diff --git a/lighthouse_cli/display.py b/lighthouse_cli/display.py index 2e071df..e6800f2 100644 --- a/lighthouse_cli/display.py +++ b/lighthouse_cli/display.py @@ -488,6 +488,14 @@ def _safe_local_message(raw: str) -> str | None: return None +# Mirrors ``ms_errors.RELOGIN_CATEGORIES`` (a sibling layer this module may +# not import); a test keeps the two equal. +_RENEWAL_FAILURE_CATEGORIES = frozenset({ + "credentials_rejected", "code_rejected", "method_unavailable", "clock_skew", + "interrupt", "network", "store", "busy", "pending_human_login", "unknown", +}) +_RENEWAL_FAILURE_RE = re.compile(r"\(automatic sign-in failed: ([a-z_]{1,32})\)") + _STATUS_CATEGORIES = { 401: "Session expired", 403: "Permission denied", 404: "Not found", 429: "Rate limited", } @@ -534,6 +542,12 @@ def format_user_error(error_value: BaseException | str) -> str: # The auth exception has a detailed __str__ containing nested recovery and # transport text. Its category and fixed recovery command are enough. if name in {"sessionexpirederror", "sessionexpired"} or "session expired" in lowered: + renewal = _RENEWAL_FAILURE_RE.search(raw) + if renewal and renewal.group(1) in _RENEWAL_FAILURE_CATEGORIES: + return ( + f"Session expired; automatic sign-in failed ({renewal.group(1)}). " + "Run: lighthouse auth login" + ) return "Session expired. Run: lighthouse auth login" # Submission rate limiting is deliberately not retried by the API client. diff --git a/lighthouse_cli/ms_auth.py b/lighthouse_cli/ms_auth.py index f19de05..1002562 100644 --- a/lighthouse_cli/ms_auth.py +++ b/lighthouse_cli/ms_auth.py @@ -71,6 +71,7 @@ PlaywrightUnavailableError, safe_diagnostic_text, safe_upstream_text, + sso_error_category, ) from lighthouse_cli.ms_mfa import ( MfaProbeResult, @@ -431,9 +432,44 @@ def build_sso_error(code: int | None, msg: str | None, step: str) -> MicrosoftSS f"Authentication failed: {description}", step=step, recovery=recovery, + category=sso_error_category(code) if code else None, ) +def _server_error(status: int, step: str) -> MicrosoftSSOError: + """Fixed ``network`` error for an HTTP 5xx; only the status is rendered.""" + return MicrosoftSSOError( + f"The sign-in service returned a server error (HTTP {status}). Try again later.", + step=step, + recovery="Wait a few minutes, then retry.", + category="network", + ) + + +def _raise_for_server_error(resp: requests.Response, step: str) -> None: + """Raise a ``network`` error for a 5xx response, before any page parsing. + + A short Microsoft or Lighthouse outage is then a transient failure + rather than an unrecognized page. + """ + status = getattr(resp, "status_code", None) + if isinstance(status, int) and not isinstance(status, bool) and status >= 500: + raise _server_error(status, step) + + +def _is_navigation_outage(exc: BaseException) -> bool: + """True when a Playwright ``goto`` could not reach the page at all. + + The exception message is inspected only to classify; it is never shown. + """ + api = sys.modules.get("playwright.sync_api") + timeout_cls = getattr(api, "TimeoutError", None) + error_cls = getattr(api, "Error", None) + if isinstance(timeout_cls, type) and isinstance(exc, timeout_cls): + return True + return isinstance(error_cls, type) and isinstance(exc, error_cls) and "net::ERR_" in str(exc) + + def build_password_form_data( config: dict[str, Any], username: str, password: str ) -> dict[str, str]: @@ -802,6 +838,9 @@ def __init__( # form field NAMES, page shape). Never request/response bodies, never # headers, cookies, tokens, or query strings. self._flow_log = flow_log or os.environ.get("LIGHTHOUSE_DEBUG_FLOW") or "" + # False only during a stored-authenticator login, which must never + # read, merge or delete a human's pending MFA checkpoint. + self._checkpoint_enabled = True def _new_session(self) -> requests.Session: session = requests.Session() @@ -844,8 +883,12 @@ def _record_flow( except OSError: pass # diagnostics must never break the login flow - def _get(self, url: str, **kwargs: Any) -> requests.Response: - """GET with timeout and allow_redirects=False.""" + def _get(self, url: str, *, _best_effort: bool = False, **kwargs: Any) -> requests.Response: + """GET with timeout and allow_redirects=False. + + A 5xx raises a ``network`` error unless ``_best_effort`` marks an + optional probe whose response is ignored. + """ resp = self._session.get( url, allow_redirects=False, @@ -853,10 +896,12 @@ def _get(self, url: str, **kwargs: Any) -> requests.Response: **kwargs, ) self._record_flow("GET", url, resp.status_code) + if not _best_effort: + _raise_for_server_error(resp, f"GET {urlparse(url).hostname or 'request'}") return resp - def _post(self, url: str, **kwargs: Any) -> requests.Response: - """POST with timeout and allow_redirects=False.""" + def _post(self, url: str, *, _best_effort: bool = False, **kwargs: Any) -> requests.Response: + """POST with timeout and allow_redirects=False (5xx handled as in ``_get``).""" data = kwargs.get("data") if isinstance(data, dict): self._record_flow("POST", url, field_names=sorted(data.keys())) @@ -867,6 +912,8 @@ def _post(self, url: str, **kwargs: Any) -> requests.Response: **kwargs, ) self._record_flow("POST", url, resp.status_code) + if not _best_effort: + _raise_for_server_error(resp, f"POST {urlparse(url).hostname or 'request'}") return resp def _post_with_redirects(self, url: str, **kwargs: Any) -> requests.Response: @@ -935,6 +982,8 @@ def _resolve_mfa_url(base_url: str, path: str) -> str: def _checkpoint_mfa_pending(self, **updates: Any) -> None: """Persist in-progress MFA state so verify can resume after interruptions.""" + if not self._checkpoint_enabled: + return from lighthouse_cli.config import update_mfa_pending updates.setdefault("cookies", _export_session_cookies(self._session)) @@ -1057,6 +1106,7 @@ def login( on_credentials_submitted: Callable[[], None] | None = None, read_totp_after_challenge: bool = False, defer_mfa_to_pending: bool = False, + totp_provider: Callable[[], str] | None = None, ) -> dict[str, str]: """Execute the full login flow and return D2L session cookies. @@ -1070,6 +1120,10 @@ def login( BeginAuth sends an SMS/OTP (used with ``--totp -``). defer_mfa_to_pending: If True, stop after BeginAuth and save session for ``lighthouse auth verify`` (no second BeginAuth on verify). + totp_provider: Stored-authenticator mode. Called once, after + BeginAuth, for the offline PhoneAppOTP code; EndAuth is then + submitted once. Requires ``mfa_method="app"`` and no other + code source, and never touches the pending MFA checkpoint. Returns: Dict mapping cookie names (d2lSecureSessionVal, etc.) to values. @@ -1083,12 +1137,51 @@ def login( f"Invalid mfa_method {mfa_method!r}. Use: {', '.join(VALID_MFA_METHODS)}", step="MFA", ) + if totp_provider is not None and ( + mfa_method != MFA_METHOD_APP + or totp_code is not None + or read_totp_after_challenge + or defer_mfa_to_pending + ): + raise MicrosoftSSOError( + "A stored authenticator requires --mfa-method app and no other code source.", + step="MFA", + ) + self._checkpoint_enabled = totp_provider is None + try: + return self._login_flow( + username, + password, + totp_code, + mfa_method=mfa_method, + on_credentials_submitted=on_credentials_submitted, + read_totp_after_challenge=read_totp_after_challenge, + defer_mfa_to_pending=defer_mfa_to_pending, + totp_provider=totp_provider, + ) + finally: + self._checkpoint_enabled = True + + def _login_flow( + self, + username: str, + password: str, + totp_code: str | None, + *, + mfa_method: str, + on_credentials_submitted: Callable[[], None] | None, + read_totp_after_challenge: bool, + defer_mfa_to_pending: bool, + totp_provider: Callable[[], str] | None, + ) -> dict[str, str]: + """The :meth:`login` flow after argument validation.""" # Starting a new login invalidates any previous BeginAuth checkpoint. # Resume uses ``complete_mfa_pending`` and never enters this method. from lighthouse_cli.config import clear_mfa_pending - clear_mfa_pending() + if self._checkpoint_enabled: + clear_mfa_pending() # Create a fresh session for each login attempt (safe reuse). self._session = self._new_session() @@ -1127,6 +1220,7 @@ def login( mfa_method=mfa_method, read_totp_after_challenge=read_totp_after_challenge, defer_mfa_to_pending=defer_mfa_to_pending, + totp_provider=totp_provider, ) saml_response = extract_saml_response(snap.html) elif is_error_page(snap): @@ -1154,7 +1248,8 @@ def login( # Step 6: Extract D2L cookies. Only after the complete inline flow has # produced a valid session do we remove a stale/recovery checkpoint. cookies = self._extract_d2l_cookies() - clear_mfa_pending() + if self._checkpoint_enabled: + clear_mfa_pending() return cookies def probe_mfa_methods(self, username: str, password: str) -> MfaProbeResult: @@ -1367,6 +1462,7 @@ def _post_dsso_status(self, config: dict[str, Any], canary: str) -> None: self._record_flow("POST", url, field_names=["resultCode", "ssoDelay", "log"]) self._post( url, + _best_effort=True, json={ "resultCode": 2, "ssoDelay": 0, @@ -1457,7 +1553,20 @@ def _bootstrap_username_via_playwright( if export_cookies: context.add_cookies(export_cookies) page = context.new_page() - page.goto(ms_url, wait_until="networkidle", timeout=60000) + try: + nav = page.goto(ms_url, wait_until="networkidle", timeout=60000) + except Exception as exc: + if _is_navigation_outage(exc): + raise MicrosoftSSOError( + "The Microsoft sign-in page could not be reached. Try again later.", + step="prepare username", + recovery="Check your connection, then retry.", + category="network", + ) from None + raise + nav_status = getattr(nav, "status", None) + if isinstance(nav_status, int) and not isinstance(nav_status, bool) and nav_status >= 500: + raise _server_error(nav_status, "prepare username") login_input = page.query_selector('input[name="loginfmt"]') if login_input: page.fill('input[name="loginfmt"]', username) @@ -1528,6 +1637,7 @@ def _step_prepare_username_http( self._get( "https://login.live.com/Me.htm?v=3", + _best_effort=True, headers={"Referer": referer}, ) @@ -1535,14 +1645,18 @@ def _step_prepare_username_http( "https://autologon.microsoftazuread-sso.com/" f"{tenant_id}/winauth/ssoprobe?client-request-id={client_request_id}" ) - self._get(ssoprobe_url, headers={"Referer": referer}) + self._get(ssoprobe_url, _best_effort=True, headers={"Referer": referer}) canary_hdr = str(config.get("apiCanary") or config.get("canary") or "") self._post_dsso_status(config, canary_hdr) updated = self._step_get_credential_type(config, username) - self._get(f"{ssoprobe_url}&_={int(time.time() * 1000)}", headers={"Referer": referer}) + self._get( + f"{ssoprobe_url}&_={int(time.time() * 1000)}", + _best_effort=True, + headers={"Referer": referer}, + ) post_gct_canary = str(updated.get("apiCanary") or canary_hdr) self._post_dsso_status(updated, post_gct_canary) @@ -1639,6 +1753,7 @@ def _step_get_credential_type( resp.status_code, field_names=gct_keys, ) + _raise_for_server_error(resp, "prepare username") if resp.status_code != 200 or not isinstance(data, dict): return config @@ -1837,6 +1952,7 @@ def _step_handle_mfa( mfa_method: str = MFA_METHOD_AUTO, read_totp_after_challenge: bool = False, defer_mfa_to_pending: bool = False, + totp_provider: Callable[[], str] | None = None, ) -> ResponseSnapshot: """Step 4: Handle MFA — ConvergedTFA SAS API or legacy form fallback.""" mfa_config = _extract_config_json(mfa_snap.html) or {} @@ -1850,6 +1966,7 @@ def _step_handle_mfa( mfa_method=mfa_method, read_totp_after_challenge=read_totp_after_challenge, defer_mfa_to_pending=defer_mfa_to_pending, + totp_provider=totp_provider, ) return self._step_handle_mfa_legacy_form( mfa_snap, @@ -1857,6 +1974,7 @@ def _step_handle_mfa( totp_code, mfa_config=mfa_config, mfa_method=mfa_method, + totp_provider=totp_provider, ) def _step_handle_mfa_converged( @@ -1869,9 +1987,17 @@ def _step_handle_mfa_converged( mfa_method: str, read_totp_after_challenge: bool = False, defer_mfa_to_pending: bool = False, + totp_provider: Callable[[], str] | None = None, ) -> ResponseSnapshot: """Handle ConvergedTFA via BeginAuth → EndAuth → ProcessAuth.""" selected = _select_user_proof(proofs, mfa_method) + if totp_provider is not None and selected.auth_method_id != MFA_AUTH_APP_OTP: + raise MicrosoftSSOError( + "The account offers no authenticator-app code method.", + step="MFA", + recovery="Register an authenticator app, then run: lighthouse auth totp-setup", + category="method_unavailable", + ) is_codeless = selected.auth_method_id in CODELESS_APPROVAL_AUTH_IDS if totp_code is not None and selected.auth_method_id != MFA_AUTH_APP_OTP: raise MicrosoftSSOError( @@ -1920,7 +2046,8 @@ def _step_handle_mfa_converged( ) code_sent_on_begin = selected.auth_method_id in SERVER_SENT_CODE_AUTH_IDS - self._print_mfa_phase_banner(proofs, selected, code_sent_on_begin=code_sent_on_begin) + if totp_provider is None: + self._print_mfa_phase_banner(proofs, selected, code_sent_on_begin=code_sent_on_begin) if defer_mfa_to_pending: from lighthouse_cli.config import save_mfa_pending @@ -1968,23 +2095,28 @@ def _step_handle_mfa_converged( recovery="Run: lighthouse auth verify (use the code from this message)", ) - if not is_codeless: + if totp_provider is not None: + # Generated only now, after BeginAuth, so a slow username step + # cannot age the code. + totp_code = totp_provider() + elif not is_codeless: totp_code = self._collect_totp_after_challenge( selected, totp_code, read_totp_after_challenge=read_totp_after_challenge, code_sent_on_begin=code_sent_on_begin, ) - if not totp_code: - raise MicrosoftSSOError( - "2FA code is required but was empty.", - step="MFA", - recovery="Provide a code when prompted or use: lighthouse auth verify ", - ) + if not is_codeless and not totp_code: + raise MicrosoftSSOError( + "2FA code is required but was empty.", + step="MFA", + recovery="Provide a code when prompted or use: lighthouse auth verify ", + ) return self._mfa_finish_after_begin( mfa_snap.url, mfa_config, selected, begin_data, totp_code or "", str(mfa_config.get("sPOST_Username") or ""), + single_submission=totp_provider is not None, ) def _mfa_finish_after_begin( @@ -1999,6 +2131,7 @@ def _mfa_finish_after_begin( skip_end_auth: bool = False, end_auth_flow: str | None = None, end_auth_ctx: str | None = None, + single_submission: bool = False, ) -> ResponseSnapshot: """EndAuth + ProcessAuth after a successful BeginAuth.""" process_url = mfa_config.get("urlPost") or "/common/SAS/ProcessAuth" @@ -2014,6 +2147,7 @@ def _mfa_finish_after_begin( skip_end_auth=skip_end_auth, end_auth_flow=end_auth_flow, end_auth_ctx=end_auth_ctx, + single_submission=single_submission, ) # ProcessAuth: EndAuth already consumed the OTP; only pass tokens (saml2aws pattern). @@ -2046,9 +2180,14 @@ def _poll_end_auth( skip_end_auth: bool = False, end_auth_flow: str | None = None, end_auth_ctx: str | None = None, + single_submission: bool = False, ) -> tuple[str, str, dict[str, Any]]: """Poll the EndAuth API until MFA verification succeeds. + With ``single_submission`` (stored-authenticator mode) the code is + submitted once: any non-success response, including ``Retry``, raises + a fixed ``code_rejected`` error and nothing is resubmitted. + Returns: Tuple of (flow_token, ctx, end_data) for use in ProcessAuth. """ @@ -2111,6 +2250,15 @@ def _poll_end_auth( end_auth_ctx=str(end_data.get("Ctx") or end_ctx), ) break + if single_submission: + # Raised before the AuthenticationPreviouslyCompleted handler, + # which would read another flow's pending checkpoint. + raise MicrosoftSSOError( + "The authenticator code was rejected.", + step="MFA", + recovery="Run: lighthouse auth login", + category="code_rejected", + ) if not end_data.get("Retry"): err_code = end_data.get("ErrCode") result = str(end_data.get("ResultValue") or end_data.get("Message") or "") @@ -2131,11 +2279,14 @@ def _poll_end_auth( step="MFA verify", recovery="If login succeeded but cookies were not saved, run verify again once.", ) + # Only a known code is rendered, never an arbitrary number. detail = safe_upstream_text( result, fallback=( str(err_code) - if isinstance(err_code, int) and not isinstance(err_code, bool) + if isinstance(err_code, int) + and not isinstance(err_code, bool) + and err_code in MS_ERROR_CODES else "unknown error" ), ) @@ -2188,10 +2339,21 @@ def _step_handle_mfa_legacy_form( *, mfa_config: dict[str, Any], mfa_method: str, + totp_provider: Callable[[], str] | None = None, ) -> ResponseSnapshot: """Legacy MFA form POST (older Microsoft pages without arrUserProofs).""" import getpass as _getpass + if totp_provider is not None: + # The form carries no proof identity, so it cannot be confirmed + # as an offline authenticator code: fail closed. + raise MicrosoftSSOError( + "This sign-in page cannot use the stored authenticator.", + step="MFA", + recovery="Run: lighthouse auth login", + category="method_unavailable", + ) + if totp_code is not None and mfa_method != MFA_METHOD_APP: raise MicrosoftSSOError( "A pre-provided --totp code cannot be validated for a legacy MFA form.", diff --git a/lighthouse_cli/ms_errors.py b/lighthouse_cli/ms_errors.py index 31c33ba..7af7bff 100644 --- a/lighthouse_cli/ms_errors.py +++ b/lighthouse_cli/ms_errors.py @@ -205,17 +205,60 @@ def safe_diagnostic_text(value: object, *, fallback: str) -> str: } +#: Failure categories for stored-authenticator sign-in. The values are fixed +#: identifiers: safe to print, store in ``relogin_state.json`` and return in +#: ``--json`` output. +RELOGIN_CATEGORIES = frozenset({ + "credentials_rejected", # bad password or an account problem + "code_rejected", # the authenticator code was not accepted + "method_unavailable", # no PhoneAppOTP proof, or a legacy MFA form + "clock_skew", # no fresh time step is available + "interrupt", # unrecognized page, registration nudge, policy + "network", # transport failure or a server-side outage + "store", # key source, decryption, lock-file or state-file failure + "busy", # another sign-in held the lock past the deadline + "pending_human_login", # a human's MFA checkpoint exists + "unknown", # anything else +}) + +# Codes that mean the password or the account itself was refused. Every other +# known code is an ``interrupt``; 50059 is a recognized outage (``network``). +_CREDENTIAL_ERROR_CODES = frozenset({50034, 50053, 50055, 50056, 50057, 50126, 50133, 50144}) +_NETWORK_ERROR_CODES = frozenset({50059}) + + +def sso_error_category(code: int) -> str | None: + """Return the failure category of a known Microsoft error code, or None.""" + if code in _CREDENTIAL_ERROR_CODES: + return "credentials_rejected" + if code in _NETWORK_ERROR_CODES: + return "network" + return "interrupt" if code in MS_ERROR_CODES else None + + # --------------------------------------------------------------------------- # Exceptions # --------------------------------------------------------------------------- class MicrosoftSSOError(Exception): - """Raised when any step of the Microsoft SSO flow fails.""" + """Raised when any step of the Microsoft SSO flow fails. + + ``category`` is one of ``RELOGIN_CATEGORIES`` when the failure was + classified at its source; it is not part of the rendered message. + """ - def __init__(self, message: str, step: str | None = None, recovery: str | None = None) -> None: + def __init__( + self, + message: str, + step: str | None = None, + recovery: str | None = None, + *, + category: str | None = None, + ) -> None: super().__init__(message) self.step = step self.recovery = recovery + self.category = category if category in RELOGIN_CATEGORIES else None def __str__(self) -> str: parts = [super().__str__()] diff --git a/lighthouse_cli/ms_mfa.py b/lighthouse_cli/ms_mfa.py index cfe878e..7a43304 100644 --- a/lighthouse_cli/ms_mfa.py +++ b/lighthouse_cli/ms_mfa.py @@ -167,6 +167,7 @@ def _select_user_proof(proofs: list[UserProof], preference: str) -> UserProof: f"Requested MFA method '{preference}' is not available. Options: {available}", step="MFA", recovery="Use --mfa-method auto, choose, or register the method in Microsoft security settings.", + category="method_unavailable", ) # auto: tenant default, else first registered method diff --git a/lighthouse_cli/session_import.py b/lighthouse_cli/session_import.py index 9adf360..5122873 100644 --- a/lighthouse_cli/session_import.py +++ b/lighthouse_cli/session_import.py @@ -3,6 +3,8 @@ from __future__ import annotations import sys +from contextlib import AbstractContextManager, nullcontext +from typing import NoReturn import click @@ -10,8 +12,21 @@ from .connection import active_connection from .credential_store import CredentialStore from .display import JsonOutputCommand, output_json, utc_now_iso +from .session_renewal import RenewalError, renewal_lock from .utils import _loads_strict_json +_IMPORT_FAILED = ( + "Session import failed. Check the input origin, required cookies and encryption key source." +) +_SIGN_IN_BUSY = "Another sign-in is in progress. Run the command again." + + +def _fail(message: str, json_output: bool) -> NoReturn: + click.echo(message, err=True) + if json_output: + output_json({"imported": False, "error": message}) + raise SystemExit(1) + @click.command("import-session", cls=JsonOutputCommand) @click.option("--json", "json_output", is_flag=True) @@ -40,17 +55,20 @@ def import_session(json_output: bool) -> None: if missing_cookie_names(cookies): raise ValueError() store = CredentialStore(config_dir=connection.cookie_dir) - store.write_artifact( - store.cookie_file, - metadata={"extracted_at": utc_now_iso()}, - secret={"origin": connection.origin, "cookies": cookies}, + # The built-in session is also written by automatic sign-in. + lock: AbstractContextManager[None] = ( + renewal_lock() if connection.cookie_dir is None else nullcontext() ) + with lock: + store.write_artifact( + store.cookie_file, + metadata={"extracted_at": utc_now_iso()}, + secret={"origin": connection.origin, "cookies": cookies}, + ) + except RenewalError as exc: + _fail(_SIGN_IN_BUSY if exc.category == "busy" else _IMPORT_FAILED, json_output) except Exception: - message = "Session import failed. Check the input origin, required cookies and encryption key source." - click.echo(message, err=True) - if json_output: - output_json({"imported": False, "error": message}) - raise SystemExit(1) from None + _fail(_IMPORT_FAILED, json_output) if json_output: output_json({"imported": True, "verified": False}) else: diff --git a/lighthouse_cli/session_renewal.py b/lighthouse_cli/session_renewal.py new file mode 100644 index 0000000..0040efc --- /dev/null +++ b/lighthouse_cli/session_renewal.py @@ -0,0 +1,562 @@ +"""Unattended D2L sign-in with the stored authenticator secret. + +When the D2L session expires, :func:`renew_session` signs in again through +Microsoft SSO using the stored password and a generated authenticator +(PhoneAppOTP) code, saves the new cookies and checks them. Everything runs +under one sign-in lock shared by threads and processes, so concurrent +callers reuse a single renewal instead of each signing in. + +A circuit breaker in ``relogin_state.json`` (plaintext, never secret) stops +automatic attempts after an authentication-class failure until a person signs +in, and spaces out attempts after a network failure. + +Nothing here prints. Errors carry fixed text and a category from +``RELOGIN_CATEGORIES``; they never contain a password, code or cookie. +""" + +from __future__ import annotations + +import fcntl +import hmac +import json +import os +import stat +import threading +import time +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager, suppress +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal + +import requests + +from .config import COOKIE_NAMES, load_cookies, missing_cookie_names, save_cookies +from .credential_store import ( + CredentialStore, + CredentialStoreError, + StoredTotp, + _validate_credential_path, +) +from .ms_auth import MicrosoftSSOClient +from .ms_errors import MFA_METHOD_APP, RELOGIN_CATEGORIES, MicrosoftSSOError +from .totp import TotpParams, time_step, totp_code +from .utils import _loads_strict_json, atomic_write + +SessionProbe = Literal["valid", "invalid", "unavailable"] + +LOCK_TIMEOUT_SECONDS = 120.0 +NETWORK_COOLDOWN_SECONDS = 300.0 +_LOCK_POLL_SECONDS = 0.25 +_STEP_MARGIN_SECONDS = 0.05 +_LOCK_FILE = "relogin.lock" +_STATE_FILE = "relogin_state.json" +_STATE_VERSION = 1 +_STATE_KEYS = frozenset({"v", "blocked", "category", "since", "last_attempt"}) + +#: Failures that switch automatic sign-in off until a person signs in. +BLOCKING_CATEGORIES = frozenset({ + "credentials_rejected", "code_rejected", "method_unavailable", + "clock_skew", "interrupt", "unknown", +}) +#: Local conditions: neither blocked nor recorded. +_UNRECORDED_CATEGORIES = frozenset({"store", "busy", "pending_human_login"}) + +_MESSAGES = { + "credentials_rejected": "Microsoft rejected the stored username or password.", + "code_rejected": "The authenticator code was rejected.", + "method_unavailable": "The account offers no usable authenticator-app code method.", + "clock_skew": "No fresh authenticator time step is available. Check the system clock.", + "interrupt": "Microsoft sign-in stopped at a page that needs a person.", + "network": "The sign-in service could not be reached.", + "store": "Local sign-in data could not be read or written.", + "busy": "Another sign-in is in progress.", + "pending_human_login": "A two-step sign-in is waiting for verification.", + "unknown": "Automatic sign-in failed unexpectedly.", +} + +# Indirection so tests can drive the clock without patching ``time``. +_clock: Callable[[], float] = time.time +_sleep: Callable[[float], None] = time.sleep + + +class RenewalError(Exception): + """A sign-in attempt failed or was refused; ``category`` says why. + + The message is fixed per category and never contains secrets. + """ + + def __init__(self, category: str) -> None: + if category not in RELOGIN_CATEGORIES: + category = "unknown" + super().__init__(_MESSAGES[category]) + self.category = category + + +class RenewalUnavailableError(Exception): + """Unattended sign-in is not set up for the credentials in use.""" + + def __init__(self) -> None: + super().__init__( + "No stored authenticator matches the saved credentials. " + "Run: lighthouse auth totp-setup" + ) + + +@dataclass(frozen=True) +class RenewalResult: + """Fresh D2L cookies. ``signed_in`` is False when another caller's + renewal was reused instead of signing in again.""" + + cookies: dict[str, str] = field(repr=False) + signed_in: bool + + +@dataclass(frozen=True) +class _BreakerState: + blocked: bool = False + category: str | None = None + since: str | None = None + last_attempt: str | None = None + + +# Corrupt state fails closed. +_CORRUPT_STATE = _BreakerState(blocked=True, category="unknown") + + +# --------------------------------------------------------------------------- +# Lock +# --------------------------------------------------------------------------- + +_THREAD_LOCK = threading.Lock() +_owner = threading.local() + + +def _open_lock_file(store: CredentialStore) -> int: + try: + _validate_credential_path(store.config_dir) + store.config_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + lock_path = store.config_dir / _LOCK_FILE + _validate_credential_path(lock_path) + descriptor = os.open( + lock_path, + os.O_CREAT | os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK, + 0o600, + ) + except (CredentialStoreError, OSError): + raise RenewalError("store") from None + try: + regular = stat.S_ISREG(os.fstat(descriptor).st_mode) + except OSError: + regular = False + if not regular: + os.close(descriptor) + raise RenewalError("store") + return descriptor + + +def _flock_until(descriptor: int, deadline: float) -> None: + while True: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + return + except BlockingIOError: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RenewalError("busy") from None + _sleep(min(_LOCK_POLL_SECONDS, remaining)) + except OSError: + raise RenewalError("store") from None + + +@contextmanager +def renewal_lock(*, timeout: float | None = None) -> Iterator[None]: + """Hold the sign-in lock: a process-wide thread lock, then an exclusive + ``flock`` on ``/relogin.lock``. + + One monotonic deadline (default ``LOCK_TIMEOUT_SECONDS``) covers both. + Raises ``RenewalError("busy")`` on timeout and ``RenewalError("store")`` + when the lock file is unusable. The lock is not re-entrant; re-entry from + the holding thread raises at once instead of waiting out the deadline. + """ + if getattr(_owner, "held", False): + raise RuntimeError("renewal_lock is not re-entrant.") + if timeout is None: + timeout = LOCK_TIMEOUT_SECONDS + deadline = time.monotonic() + timeout + if not _THREAD_LOCK.acquire(timeout=max(0.0, timeout)): + raise RenewalError("busy") + try: + descriptor = _open_lock_file(CredentialStore()) + try: + _flock_until(descriptor, deadline) + _owner.held = True + try: + yield + finally: + _owner.held = False + finally: + os.close(descriptor) + finally: + _THREAD_LOCK.release() + + +# --------------------------------------------------------------------------- +# Circuit breaker state +# --------------------------------------------------------------------------- + + +def _utc_now() -> datetime: + return datetime.fromtimestamp(_clock(), tz=timezone.utc) + + +def _parse_utc(value: object) -> datetime: + """Parse an ISO-8601 UTC timestamp; raise ValueError for anything else.""" + if not isinstance(value, str): + raise ValueError("timestamp") + parsed = datetime.fromisoformat(value) + offset = parsed.utcoffset() + if offset is None or offset.total_seconds() != 0: + raise ValueError("timestamp") + return parsed + + +def _state_from_document(doc: object) -> _BreakerState: + if type(doc) is not dict or set(doc) != _STATE_KEYS: + return _CORRUPT_STATE + blocked, category = doc["blocked"], doc["category"] + if type(doc["v"]) is not int or doc["v"] != _STATE_VERSION or type(blocked) is not bool: + return _CORRUPT_STATE + allowed = BLOCKING_CATEGORIES if blocked else {None, "network"} + if category not in allowed: + return _CORRUPT_STATE + try: + for key in ("since", "last_attempt"): + if doc[key] is not None: + _parse_utc(doc[key]) + except ValueError: + return _CORRUPT_STATE + return _BreakerState(blocked, category, doc["since"], doc["last_attempt"]) + + +def _state_path(store: CredentialStore) -> Path: + path = store.config_dir / _STATE_FILE + _validate_credential_path(path) + return path + + +def _load_state(store: CredentialStore) -> _BreakerState: + """Read the breaker. Missing means clear; unreadable content is blocked.""" + try: + raw = _state_path(store).read_bytes() + except FileNotFoundError: + return _BreakerState() + except (CredentialStoreError, OSError): + raise RenewalError("store") from None + try: + doc = _loads_strict_json(raw) + except ValueError: + return _CORRUPT_STATE + return _state_from_document(doc) + + +def _write_state(store: CredentialStore, state: _BreakerState) -> None: + doc = { + "v": _STATE_VERSION, + "blocked": state.blocked, + "category": state.category, + "since": state.since, + "last_attempt": state.last_attempt, + } + try: + atomic_write(_state_path(store), json.dumps(doc), mode=0o600) + except (CredentialStoreError, OSError): + raise RenewalError("store") from None + + +def _record_failure(store: CredentialStore, state: _BreakerState, category: str) -> None: + """Block on an auth-class failure; start a cooldown on ``network``. + + A non-blocking failure never clears an existing block. + """ + if category in _UNRECORDED_CATEGORIES: + return + blocked = category in BLOCKING_CATEGORIES + if state.blocked and not blocked: + return + now = _utc_now().isoformat() + since = state.since if state.category == category and state.since else now + _write_state(store, _BreakerState(blocked, category, since, now)) + + +def clear_breaker() -> None: + """Remove ``relogin_state.json``. Call while holding :func:`renewal_lock`.""" + store = CredentialStore() + try: + _state_path(store).unlink() + except FileNotFoundError: + pass + except (CredentialStoreError, OSError): + raise RenewalError("store") from None + + +def store_authenticator(username: str, params: TotpParams) -> None: + """Bind ``params`` to ``username`` under the sign-in lock; clear the breaker. + + The current time step counts as used, so the code just typed to register + the app is never sent again. Re-storing the same key keeps a newer + recorded step. + """ + with renewal_lock(): + store = CredentialStore() + last_step = time_step(_clock(), params.period) + try: + previous = store.load_totp() + except CredentialStoreError: + previous = None # replaced below + if ( + previous is not None + and previous.last_step is not None + and previous.username.casefold() == username.casefold() + and hmac.compare_digest(previous.params.key, params.key) + ): + last_step = max(last_step, previous.last_step) + store.save_totp(username, params, last_step=last_step) + clear_breaker() + + +def remove_authenticator() -> bool: + """Delete the stored authenticator and the breaker state under the lock.""" + with renewal_lock(): + removed = CredentialStore().delete_totp() + clear_breaker() + return removed + + +def unattended_configured(config_dir: Path | None = None) -> bool: + """Cheap check: a stored authenticator and saved credentials both exist. + + No decryption and no network. + """ + store = CredentialStore(config_dir=config_dir) + try: + return store.authenticator_file.is_file() and store.credentials_file.is_file() + except OSError: + return False + + +def unattended_status() -> dict[str, bool]: + """``{"configured": ..., "blocked": ...}`` for ``auth status``. + + Unreadable breaker state reports blocked. + """ + try: + blocked = _load_state(CredentialStore()).blocked + except RenewalError: + blocked = True + return {"configured": unattended_configured(), "blocked": blocked} + + +# --------------------------------------------------------------------------- +# Renewal +# --------------------------------------------------------------------------- + + +def _as_renewal_error(exc: BaseException) -> RenewalError: + if isinstance(exc, RenewalError): + return exc + if isinstance(exc, MicrosoftSSOError): + return RenewalError(exc.category or "interrupt") + # RequestException subclasses OSError, so it must be checked first. + if isinstance(exc, requests.RequestException): + return RenewalError("network") + if isinstance(exc, (CredentialStoreError, OSError)): + return RenewalError("store") + return RenewalError("unknown") + + +def _same_session(current: Mapping[str, str], observed: Mapping[str, str]) -> bool: + return all(current.get(name) == observed.get(name) for name in COOKIE_NAMES) + + +def _reuse_existing( + observed: Mapping[str, str], validate: Callable[[dict[str, str]], SessionProbe] +) -> dict[str, str] | None: + """Cookies another caller saved since ``observed`` was read, if valid.""" + current = load_cookies(read_only=True) + if missing_cookie_names(current) or _same_session(current, observed): + return None + probe = validate(dict(current)) + if probe == "valid": + return current + if probe == "unavailable": + raise RenewalError("network") + return None + + +def _check_gates(store: CredentialStore, state: _BreakerState) -> None: + """Refuse an automatic attempt; nothing here is recorded.""" + if state.blocked: + raise RenewalError(state.category or "unknown") + if state.category == "network" and state.last_attempt: + elapsed = (_utc_now() - _parse_utc(state.last_attempt)).total_seconds() + if 0 <= elapsed < NETWORK_COOLDOWN_SECONDS: + raise RenewalError("network") + try: + _validate_credential_path(store.mfa_pending_file) + pending = store.mfa_pending_file.exists() + except (CredentialStoreError, OSError): + raise RenewalError("store") from None + if pending: + raise RenewalError("pending_human_login") + + +def _code_provider(store: CredentialStore, stored: StoredTotp) -> Callable[[], str]: + """Build the one-shot provider ``ms_auth`` calls after BeginAuth. + + It reserves a time step newer than the last one used (waiting at most one + period for the next step), persists the reservation, then returns the + code for that step. + """ + params = stored.params + + def provide() -> str: + now = _clock() + step = time_step(now, params.period) + last = stored.last_step + if last is not None and step <= last: + wait = (last + 1) * params.period - now + _STEP_MARGIN_SECONDS + if 0 < wait <= params.period + 1: + _sleep(wait) + now = _clock() + step = time_step(now, params.period) + if step <= last: + raise MicrosoftSSOError( + "No fresh authenticator time step is available.", + step="MFA", + category="clock_skew", + ) + store.reserve_totp_step(step) + return totp_code(params.key, for_time=now, digits=params.digits, period=params.period) + + return provide + + +def _sign_in(store: CredentialStore, username: str, password: str, stored: StoredTotp) -> dict[str, str]: + client = MicrosoftSSOClient() + try: + return client.login( + username, + password, + mfa_method=MFA_METHOD_APP, + totp_provider=_code_provider(store, stored), + ) + finally: + with suppress(Exception): + client.close() + + +def _usable_pair( + credentials: tuple[str, str] | None, stored: StoredTotp | None +) -> tuple[str, str, StoredTotp]: + if credentials is None or stored is None: + raise RenewalUnavailableError() + username, password = credentials + if not username or not password or username.casefold() != stored.username.casefold(): + raise RenewalUnavailableError() + return username, password, stored + + +class _UnrecordedError(Exception): + """Wraps a gate refusal so it bypasses failure recording.""" + + def __init__(self, error: RenewalError) -> None: + super().__init__(str(error)) + self.error = error + + +def _attempt( + store: CredentialStore, + *, + observed: Mapping[str, str], + validate: Callable[[dict[str, str]], SessionProbe], + automatic: bool, + credentials: tuple[str, str] | None, + persist_credentials: bool, + state: _BreakerState, +) -> RenewalResult: + if automatic: + reused = _reuse_existing(observed, validate) + if reused is not None: + return RenewalResult(reused, signed_in=False) + try: + if automatic: + _check_gates(store, state) + stored = store.load_totp() + if credentials is None: + credentials = store.load() + except RenewalError as exc: + raise _UnrecordedError(exc) from None + username, password, stored_totp = _usable_pair(credentials, stored) + cookies = _sign_in(store, username, password, stored_totp) + save_cookies(cookies) + probe = validate(dict(cookies)) + if probe == "unavailable": + raise RenewalError("network") + if probe != "valid": + raise RenewalError("interrupt") + if persist_credentials: + store.save(username, password) + return RenewalResult(dict(cookies), signed_in=True) + + +def renew_session( + *, + observed_cookies: Mapping[str, str], + validate: Callable[[dict[str, str]], SessionProbe], + automatic: bool, + credentials: tuple[str, str] | None = None, + persist_credentials: bool = False, +) -> RenewalResult: + """Sign in with the stored authenticator under the sign-in lock. + + Args: + observed_cookies: The cookies the caller found expired. Automatic + renewal reuses newer valid cookies another caller saved. + validate: Probes a cookie set against D2L. + automatic: True for renewal on expiry (breaker, cooldown and pending + two-step gates apply); False for an explicit ``auth login``. + credentials: The username/password ``auth login`` resolved; ``None`` + loads the saved pair. + persist_credentials: Save ``credentials`` after a validated sign-in. + + Raises: + RenewalUnavailableError: No stored authenticator matches the credentials; + nothing was attempted. + RenewalError: Fixed-text failure with a ``category``. + """ + with renewal_lock(): + store = CredentialStore() + state = _load_state(store) + try: + result = _attempt( + store, + observed=observed_cookies, + validate=validate, + automatic=automatic, + credentials=credentials, + persist_credentials=persist_credentials, + state=state, + ) + except RenewalUnavailableError: + raise + except _UnrecordedError as exc: + raise exc.error from None + except Exception as exc: # every failure maps to a category + error = _as_renewal_error(exc) + _record_failure(store, state, error.category) + raise error from None + if result.signed_in: + clear_breaker() + return result diff --git a/lighthouse_cli/totp.py b/lighthouse_cli/totp.py new file mode 100644 index 0000000..7d31595 --- /dev/null +++ b/lighthouse_cli/totp.py @@ -0,0 +1,158 @@ +"""RFC 6238 TOTP primitives for the stored authenticator (standard library only). + +The CLI signs in unattended by generating the same 6-digit codes a +third-party authenticator app shows. This module only parses the secret and +computes codes; storage lives in ``credential_store`` and the step reservation +in ``session_renewal``. + +Errors never echo the supplied text, and ``repr`` never shows the key. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import re +import struct +import urllib.parse +from dataclasses import dataclass, field + +TOTP_ALGORITHM = "SHA1" +TOTP_DIGITS = 6 +TOTP_PERIOD = 30 + +# Entra software OATH tokens use 160-bit keys. The bounds reject truncated +# pastes and anything that is clearly not an authenticator secret. +_MIN_KEY_BYTES = 10 +_MAX_KEY_BYTES = 64 +_MAX_INPUT_CHARS = 2048 +_BASE32_RE = re.compile(r"[A-Z2-7]+") + +_INVALID_SECRET = "The authenticator secret is not valid base32." # pragma: allowlist secret +_EMPTY_SECRET = "The authenticator secret is empty." # pragma: allowlist secret +_BAD_KEY_LENGTH = "The authenticator secret has an unexpected length." +_INVALID_URI = "The otpauth:// URI could not be read." +_UNSUPPORTED_URI = ( + "Only otpauth://totp URIs with SHA1, 6 digits and a 30-second period are supported." +) +# Every message ``TotpSecretError`` can carry; callers may show these as-is. +SECRET_ERROR_MESSAGES = frozenset({ + _INVALID_SECRET, _EMPTY_SECRET, _BAD_KEY_LENGTH, _INVALID_URI, _UNSUPPORTED_URI, +}) + + +class TotpSecretError(ValueError): + """The supplied authenticator secret cannot be used. + + Messages are fixed strings; they never contain the supplied text. + """ + + +@dataclass(frozen=True) +class TotpParams: + """A parsed authenticator secret. ``repr`` never shows the key.""" + + key: bytes = field(repr=False) + digits: int = TOTP_DIGITS + period: int = TOTP_PERIOD + + @property + def secret_base32(self) -> str: + """Canonical unpadded base32 form, used only for sealed storage.""" + return base64.b32encode(self.key).decode("ascii").rstrip("=") + + +def _decode_base32(text: str) -> bytes: + """Decode a base32 secret, tolerating spaces, hyphens, case and padding.""" + normalized = "".join(text.split()).replace("-", "").upper().rstrip("=") + if not normalized: + raise TotpSecretError(_EMPTY_SECRET) + if not _BASE32_RE.fullmatch(normalized): + raise TotpSecretError(_INVALID_SECRET) + try: + key = base64.b32decode(normalized + "=" * (-len(normalized) % 8)) + except (binascii.Error, ValueError): + raise TotpSecretError(_INVALID_SECRET) from None + if not _MIN_KEY_BYTES <= len(key) <= _MAX_KEY_BYTES: + raise TotpSecretError(_BAD_KEY_LENGTH) + return key + + +def _secret_from_uri(text: str) -> str: + """Return the ``secret`` parameter of an ``otpauth://totp`` URI. + + Only the parameters Entra software OATH uses are accepted; any other + algorithm, digit count or period, an HOTP URI, or a repeated parameter is + rejected rather than guessed at. + """ + try: + parts = urllib.parse.urlsplit(text) + pairs = urllib.parse.parse_qsl( + parts.query, keep_blank_values=True, strict_parsing=True + ) + except ValueError: + raise TotpSecretError(_INVALID_URI) from None + if parts.scheme.casefold() != "otpauth" or parts.netloc.casefold() != "totp": + raise TotpSecretError(_UNSUPPORTED_URI) + params: dict[str, str] = {} + for name, value in pairs: + key = name.casefold() + if key in params: + raise TotpSecretError(_INVALID_URI) + params[key] = value + if params.get("algorithm", TOTP_ALGORITHM).upper() != TOTP_ALGORITHM: + raise TotpSecretError(_UNSUPPORTED_URI) + if params.get("digits", str(TOTP_DIGITS)) != str(TOTP_DIGITS): + raise TotpSecretError(_UNSUPPORTED_URI) + if params.get("period", str(TOTP_PERIOD)) != str(TOTP_PERIOD): + raise TotpSecretError(_UNSUPPORTED_URI) + if "secret" not in params: + raise TotpSecretError(_EMPTY_SECRET) + return params["secret"] + + +def parse_totp_secret(text: str) -> TotpParams: + """Parse a bare base32 secret or an ``otpauth://totp`` URI. + + Raises: + TotpSecretError: With a fixed message that never echoes ``text``. + """ + if not isinstance(text, str): + raise TotpSecretError(_INVALID_SECRET) + text = text.strip() + if not text: + raise TotpSecretError(_EMPTY_SECRET) + if len(text) > _MAX_INPUT_CHARS: + raise TotpSecretError(_BAD_KEY_LENGTH) + if text[:10].casefold() == "otpauth://": + text = _secret_from_uri(text) + return TotpParams(key=_decode_base32(text)) + + +def time_step(for_time: float, period: int = TOTP_PERIOD) -> int: + """Return the RFC 6238 time-step counter for a Unix timestamp.""" + return int(for_time // period) + + +def totp_code( + key: bytes, + *, + for_time: float, + digits: int = TOTP_DIGITS, + period: int = TOTP_PERIOD, +) -> str: + """Return the zero-padded RFC 6238 (HMAC-SHA1) code for ``for_time``. + + ``digits`` is 6 or 8; 8 is used only by the RFC test vectors. + """ + if digits not in (6, 8): + raise ValueError("Unsupported TOTP digit count.") + counter = time_step(for_time, period) + if counter < 0: + raise ValueError("TOTP time is before the Unix epoch.") + mac = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = mac[-1] & 0x0F + value = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF + return str(value % 10 ** digits).zfill(digits) diff --git a/pyproject.toml b/pyproject.toml index 45b6ece..32dd7b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,10 +94,12 @@ layers = [ "lighthouse_cli.commands | lighthouse_cli.quiz_learner_session | lighthouse_cli.quiz_preview_session", "lighthouse_cli.course_read_commands | lighthouse_cli.quiz_learner_finish | lighthouse_cli.quiz_preview_finish | lighthouse_cli.show | lighthouse_cli.sync_engine", "lighthouse_cli.assessment_api | lighthouse_cli.assignments | lighthouse_cli.auth | lighthouse_cli.course_config | lighthouse_cli.quiz_learner_transport | lighthouse_cli.quiz_preview_transport | lighthouse_cli.submit", - "lighthouse_cli.api | lighthouse_cli.ms_auth | lighthouse_cli.session_import", + "lighthouse_cli.api | lighthouse_cli.session_import", + "lighthouse_cli.session_renewal", + "lighthouse_cli.ms_auth", "lighthouse_cli.config", "lighthouse_cli.credential_store | lighthouse_cli.manifest | lighthouse_cli.ms_mfa | lighthouse_cli.quiz_attempt_page", - "lighthouse_cli.connection | lighthouse_cli.display | lighthouse_cli.ms_errors | lighthouse_cli.ms_parse | lighthouse_cli.ms_session | lighthouse_cli.quiz_math | lighthouse_cli.quiz_rules | lighthouse_cli.request_protection | lighthouse_cli.utils", + "lighthouse_cli.connection | lighthouse_cli.display | lighthouse_cli.ms_errors | lighthouse_cli.ms_parse | lighthouse_cli.ms_session | lighthouse_cli.quiz_math | lighthouse_cli.quiz_rules | lighthouse_cli.request_protection | lighthouse_cli.totp | lighthouse_cli.utils", ] [[tool.importlinter.contracts]] @@ -110,6 +112,7 @@ source_modules = [ "lighthouse_cli.request_protection", "lighthouse_cli.config", "lighthouse_cli.credential_store", + "lighthouse_cli.totp", "lighthouse_cli.utils", ] forbidden_modules = [ diff --git a/tests/_relogin_mp_helper.py b/tests/_relogin_mp_helper.py new file mode 100644 index 0000000..00a7c92 --- /dev/null +++ b/tests/_relogin_mp_helper.py @@ -0,0 +1,106 @@ +"""Spawn-safe helpers for unattended renewal process tests.""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +from lighthouse_cli import session_renewal as sr +from lighthouse_cli.ms_errors import MicrosoftSSOError +from lighthouse_cli.session_renewal import RenewalError + +WORKER_TIME = 1_800_000_015.0 + + +class _WorkerClock: + def __init__(self) -> None: + self.now = WORKER_TIME + + def time(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + time.sleep(min(seconds, 0.01)) + + +class _CountingSSO: + def __init__( + self, + counter_file: Path, + fresh_cookies: dict[str, str], + failure_category: str | None, + delay_seconds: float, + ) -> None: + self.counter_file = counter_file + self.fresh_cookies = fresh_cookies + self.failure_category = failure_category + self.delay_seconds = delay_seconds + self.provider_calls = 0 + + def login(self, username: str, password: str, **kwargs: Any) -> dict[str, str]: + provider = kwargs["totp_provider"] + provider() + self.provider_calls += 1 + with self.counter_file.open("a", encoding="utf-8") as counter: + counter.write("login\n") + counter.flush() + if self.delay_seconds: + time.sleep(self.delay_seconds) + if self.failure_category is not None: + raise MicrosoftSSOError("Test sign-in failure.", category=self.failure_category) + return self.fresh_cookies + + def close(self) -> None: + pass + + +def renewal_worker( + label: str, + config_dir: str, + passphrase: str, + counter_file: str, + observed_cookies: dict[str, str], + fresh_cookies: dict[str, str], + failure_category: str | None, + delay_seconds: float, + start_event: Any, + result_queue: Any, +) -> None: + """Run one renewal and report only its outcome and provider-call count.""" + os.environ["LIGHTHOUSE_CONFIG_DIR"] = config_dir + os.environ["LIGHTHOUSE_SECRETS_PASSPHRASE"] = passphrase + clock = _WorkerClock() + sr._clock = clock.time + sr._sleep = clock.sleep + fake_sso = _CountingSSO( + Path(counter_file), fresh_cookies, failure_category, delay_seconds + ) + sr.MicrosoftSSOClient = lambda: fake_sso # type: ignore[assignment] + + if start_event is not None and not start_event.wait(30): + result_queue.put((label, "start_timeout", fake_sso.provider_calls)) + return + + def validate(cookies: dict[str, str]) -> sr.SessionProbe: + if cookies == observed_cookies: + return "invalid" + if cookies == fresh_cookies: + return "valid" + return "unavailable" + + try: + result = sr.renew_session( + observed_cookies=observed_cookies, + validate=validate, + automatic=True, + ) + except RenewalError as exc: + outcome = f"blocked:{exc.category}" if failure_category is None else f"error:{exc.category}" + except BaseException: + outcome = "worker_error" + else: + outcome = "signed_in" if result.signed_in else "reused" + result_queue.put((label, outcome, fake_sso.provider_calls)) diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py index 40bda75..ba218e4 100644 --- a/tests/test_api_transport.py +++ b/tests/test_api_transport.py @@ -5,6 +5,7 @@ import asyncio import json import sys +import threading import types import urllib.request from collections.abc import Iterator @@ -15,6 +16,7 @@ import requests import lighthouse_cli.api as api +from lighthouse_cli import connection, session_renewal from lighthouse_cli.api import ( BASE_URL, MAX_HTML_TOPIC_RESPONSE_BYTES, @@ -27,6 +29,14 @@ _extract_filename, resolve_course_id, ) +from lighthouse_cli.assessment_api import AssessmentAPI, AssignmentClosedError +from lighthouse_cli.config import cookie_file_is_legacy, load_cookies, save_cookies +from lighthouse_cli.connection import LIGHTHOUSE, Connection +from lighthouse_cli.credential_store import CredentialStore +from lighthouse_cli.ms_errors import RELOGIN_CATEGORIES +from lighthouse_cli.quiz_learner_session import LearnerWorkflow, LearnerWorkflowError +from lighthouse_cli.quiz_preview_session import PreviewWorkflow, PreviewWorkflowError +from lighthouse_cli.session_renewal import RenewalError, RenewalResult, RenewalUnavailableError SYNTHETIC_COOKIES = { "d2lSameSiteCanaryA": "a", @@ -1099,3 +1109,824 @@ def test_a_403_keeps_its_status_for_the_attempt_account_check() -> None: client._do_request("GET", "https://example.test", False, 30) assert raised.value.response.status_code == 403 + + +# --------------------------------------------------------------------------- +# Session probe (whoami) and automatic sign-in +# --------------------------------------------------------------------------- + +RESOURCE_URL = BASE_URL + "/d2l/api/lp/1.47/enrollments/myenrollments/" +WHOAMI_URL = BASE_URL + "/d2l/api/lp/1.47/users/whoami" +RENEWED_COOKIES = dict.fromkeys(api.COOKIE_NAMES, "renewed-sentinel") +BROWSER_COOKIES = dict.fromkeys(api.COOKIE_NAMES, "browser-sentinel") +LEGACY_COOKIES = dict.fromkeys(api.COOKIE_NAMES, "legacy-sentinel") +PRIVATE_NAME = "Private Learner Sentinel" + + +def _whoami(identifier: object = "4242") -> FakeResponse: + return FakeResponse(200, {"Identifier": identifier, "FirstName": PRIVATE_NAME}) + + +class NonJsonResponse(FakeResponse): + def json(self) -> object: + raise ValueError(PRIVATE_NAME) + + +class JarSession: + """Session double with the cookie jar that installed cookies populate. + + Queued exceptions are raised instead of returned. + """ + + def __init__(self, responses: list[FakeResponse | Exception]) -> None: + self.responses = iter(responses) + self.calls: list[str] = [] + self.urls: list[str] = [] + self.cookies = requests.cookies.RequestsCookieJar() + + def request(self, method: str, url: str, **kwargs: object) -> FakeResponse: + self.calls.append(method) + self.urls.append(url) + item = next(self.responses) + if isinstance(item, Exception): + raise item + return item + + def close(self) -> None: + pass + + +def _jar_client( + responses: list[FakeResponse | Exception], + *, + read_only_auth: bool = False, + cookies: dict[str, str] | None = SYNTHETIC_COOKIES, +) -> tuple[LighthouseClient, JarSession]: + """A client on a fake session; ``cookies=None`` loads them from disk.""" + client = LighthouseClient(read_only_auth=read_only_auth) + client._csrf_token = "synthetic-csrf" + session = JarSession(responses) + client._session = session + if cookies is not None: + client._loaded = True + client._cookies = dict(cookies) + return client, session + + +def _jar(session: JarSession) -> dict[str, str]: + return {cookie.name: cookie.value or "" for cookie in session.cookies} + + +@pytest.fixture +def renewal(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Unattended sign-in is configured and signs in with fresh cookies.""" + monkeypatch.setattr(api, "unattended_configured", lambda: True) + renew = MagicMock(return_value=RenewalResult(dict(RENEWED_COOKIES), signed_in=True)) + monkeypatch.setattr(api, "renew_session", renew) + return renew + + +@pytest.fixture +def browser(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """A signed-in browser that CDP can copy cookies from.""" + refresh = MagicMock(return_value=dict(BROWSER_COOKIES)) + monkeypatch.setattr(api, "refresh_auth_from_browser", refresh) + return refresh + + +@contextmanager +def _sign_in_lock_held_elsewhere() -> Iterator[None]: + """Hold the sign-in lock from another thread for the duration.""" + held, release = threading.Event(), threading.Event() + + def hold() -> None: + with session_renewal.renewal_lock(): + held.set() + release.wait(10) + + thread = threading.Thread(target=hold) + thread.start() + try: + assert held.wait(5) + yield + finally: + release.set() + thread.join(5) + + +def _write_legacy_cookies() -> bytes: + path = CredentialStore().cookie_file + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + original = json.dumps({"cookies": LEGACY_COOKIES}).encode("utf-8") + path.write_bytes(original) + path.chmod(0o600) + return original + + +@pytest.mark.parametrize( + ("response", "expected"), + [ + pytest.param(_whoami("4242"), "valid", id="string-identifier"), + pytest.param(_whoami(4242), "valid", id="integer-identifier"), + pytest.param(FakeResponse(200, {"FirstName": PRIVATE_NAME}), "invalid", id="no-identifier"), + pytest.param(_whoami("0"), "invalid", id="zero-identifier"), + pytest.param(_whoami(True), "invalid", id="boolean-identifier"), + pytest.param(FakeResponse(200, ["4242"]), "invalid", id="not-an-object"), + pytest.param(NonJsonResponse(200), "invalid", id="not-json"), + ], +) +def test_probe_reads_only_a_positive_whoami_identifier( + response: FakeResponse, + expected: str, + capsys: pytest.CaptureFixture[str], +) -> None: + client, session = _jar_client([response], read_only_auth=True) + + assert client.probe_session() == expected + + assert session.calls == ["GET"] + assert response.closed is True + captured = capsys.readouterr() + assert PRIVATE_NAME not in captured.out + captured.err + + +@pytest.mark.parametrize("status", [401, 403]) +def test_signed_out_whoami_is_invalid_for_a_read_only_client( + status: int, + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, _session = _jar_client([FakeResponse(status)], read_only_auth=True) + assert client.probe_session() == "invalid" + + client, _session = _jar_client([FakeResponse(status)], read_only_auth=True) + assert client.check_auth() is False + + renewal.assert_not_called() + browser.assert_not_called() + + +@pytest.mark.parametrize( + "failure", + [ + pytest.param(FakeResponse(503), id="server-error"), + pytest.param(requests.ConnectionError("NETWORK_SENTINEL"), id="connection-error"), + ], +) +def test_probe_reports_unavailable_when_d2l_does_not_answer( + failure: FakeResponse | Exception, +) -> None: + client, _session = _jar_client([failure] * (LighthouseClient._MAX_RETRIES + 1), read_only_auth=True) + + with patch.object(api.time, "sleep"): + assert client.probe_session() == "unavailable" + + +def test_check_auth_keeps_its_exception_contract() -> None: + # A server error still reads as signed out; a transport failure still raises. + client, _session = _jar_client([FakeResponse(503)], read_only_auth=True) + assert client.check_auth() is False + + client, _session = _jar_client( + [requests.ConnectionError("NETWORK_SENTINEL")] * (LighthouseClient._MAX_RETRIES + 1), + read_only_auth=True, + ) + with patch.object(api.time, "sleep"): + with pytest.raises(NetworkError): + client.check_auth() + + +@pytest.mark.parametrize( + ("response", "expected"), + [ + pytest.param(_whoami(), "valid", id="signed-in"), + pytest.param(FakeResponse(401), "invalid", id="signed-out"), + ], +) +def test_probe_cookies_sends_only_the_candidate_cookies( + response: FakeResponse, + expected: str, + monkeypatch: pytest.MonkeyPatch, + renewal: MagicMock, + browser: MagicMock, +) -> None: + save_cookies(dict(BROWSER_COOKIES)) + on_disk = CredentialStore().cookie_file.read_bytes() + sessions: list[JarSession] = [] + + def new_session() -> JarSession: + sessions.append(JarSession([response])) + return sessions[-1] + + monkeypatch.setattr(api.requests, "Session", new_session) + monkeypatch.setattr(api, "load_cookies", MagicMock(side_effect=AssertionError("cookies.json read"))) + + assert api.probe_cookies(dict(RENEWED_COOKIES)) == expected + + [session] = sessions + assert session.calls == ["GET"] + assert _jar(session) == RENEWED_COOKIES + renewal.assert_not_called() + browser.assert_not_called() + assert CredentialStore().cookie_file.read_bytes() == on_disk + + +def test_a_403_while_whoami_is_signed_in_stays_a_permission_error( + renewal: MagicMock, + browser: MagicMock, +) -> None: + # Genuine denials (a closed folder, a hidden course) keep their 403. + denied, signed_in = FakeResponse(403), _whoami() + client, session = _jar_client([denied, signed_in, FakeResponse(403), _whoami()]) + + with pytest.raises(requests.HTTPError) as raised: + client._request("GET", RESOURCE_URL) + + assert raised.value.response is denied + assert denied.closed + assert signed_in.closed + assert session.urls == [RESOURCE_URL, WHOAMI_URL] + + # The answer is not remembered: the session may expire in between. + with pytest.raises(requests.HTTPError): + client._request("GET", RESOURCE_URL) + assert session.urls == [RESOURCE_URL, WHOAMI_URL, RESOURCE_URL, WHOAMI_URL] + renewal.assert_not_called() + browser.assert_not_called() + + +_SIGNED_OUT_WHOAMI = [ + pytest.param(401, None, id="whoami-401"), + pytest.param(403, None, id="whoami-403"), + pytest.param(302, {"Location": "/d2l/login"}, id="whoami-login-redirect"), +] + + +@pytest.mark.parametrize("method", ["GET", "HEAD"]) +@pytest.mark.parametrize(("status", "headers"), _SIGNED_OUT_WHOAMI) +def test_a_403_from_a_signed_out_session_signs_in_once_and_replays_once( + status: int, + headers: dict[str, str] | None, + method: str, + renewal: MagicMock, + browser: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + # D2L answers some reads of an expired session with 403, not 401. + client, session = _jar_client( + [FakeResponse(403), FakeResponse(status, headers=headers), FakeResponse(200)], + ) + + assert client._request(method, RESOURCE_URL).status_code == 200 + + assert session.calls == [method, "GET", method] + assert session.urls == [RESOURCE_URL, WHOAMI_URL, RESOURCE_URL] + renewal.assert_called_once_with( + observed_cookies=SYNTHETIC_COOKIES, validate=api.probe_cookies, automatic=True, + ) + browser.assert_not_called() + assert client.renewed_session is True + assert _jar(session) == RENEWED_COOKIES + assert client._csrf_token is None + captured = capsys.readouterr() + assert captured.out == "" + assert "renewed-sentinel" not in captured.err + + +@pytest.mark.parametrize( + "whoami", + [ + pytest.param(FakeResponse(500), id="server-error"), + pytest.param(FakeResponse(503), id="unavailable"), + pytest.param(FakeResponse(429, headers={"Retry-After": "60"}), id="rate-limited"), + pytest.param(FakeResponse(302, headers={"Location": "/d2l/other"}), id="other-redirect"), + pytest.param(requests.ConnectionError("SENTINEL"), id="network"), + ], +) +def test_a_403_is_kept_when_whoami_gives_no_answer( + whoami: FakeResponse | Exception, + renewal: MagicMock, + browser: MagicMock, +) -> None: + # The check is one attempt: no retry, no backoff, no Retry-After wait. + denied = FakeResponse(403) + client, session = _jar_client([denied, whoami]) + + with patch.object(api.time, "sleep") as sleep, pytest.raises(requests.HTTPError) as raised: + client._request("GET", RESOURCE_URL) + + assert raised.value.response is denied + assert session.urls == [RESOURCE_URL, WHOAMI_URL] + sleep.assert_not_called() + renewal.assert_not_called() + browser.assert_not_called() + + +def test_the_whoami_check_has_its_own_short_timeout_and_no_request_options(renewal: MagicMock) -> None: + client, session = _jar_client([FakeResponse(403), _whoami()]) + seen: list[dict[str, object]] = [] + request = session.request + + def recording(method: str, url: str, **kwargs: object) -> FakeResponse: + seen.append(kwargs) + return request(method, url, **kwargs) + + session.request = recording # type: ignore[method-assign] + with pytest.raises(requests.HTTPError): + client._request("GET", RESOURCE_URL, stream=True, headers={"Range": "bytes=0-1"}) + + assert seen[1] == {"allow_redirects": False, "timeout": LighthouseClient._SIGNED_OUT_CHECK_TIMEOUT} + renewal.assert_not_called() + + +def test_a_403_returned_to_the_caller_is_not_checked(renewal: MagicMock) -> None: + client, session = _jar_client([FakeResponse(403)]) + + assert client._request("GET", RESOURCE_URL, _skip_raise=True).status_code == 403 + + assert session.urls == [RESOURCE_URL] + renewal.assert_not_called() + + +def test_a_signed_out_403_without_unattended_sign_in_refreshes_from_the_browser( + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(403), FakeResponse(403), FakeResponse(200)]) + + assert client._request("GET", RESOURCE_URL).status_code == 200 + + assert session.urls == [RESOURCE_URL, WHOAMI_URL, RESOURCE_URL] + browser.assert_called_once_with() + assert client.cookies == BROWSER_COOKIES + assert client.renewed_session is False + + +def test_a_signed_out_403_with_no_browser_reports_an_expired_session( + browser: MagicMock, +) -> None: + browser.side_effect = RuntimeError("no browser") + client, session = _jar_client([FakeResponse(403), FakeResponse(403)]) + + with pytest.raises(SessionExpiredError, match=r"auto-refresh failed"): + client._request("GET", RESOURCE_URL) + + assert session.urls == [RESOURCE_URL, WHOAMI_URL] + + +def test_a_403_after_signing_in_again_does_not_sign_in_twice( + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client( + [FakeResponse(403), FakeResponse(403), FakeResponse(403), FakeResponse(403)], + ) + + with pytest.raises(SessionExpiredError, match="auto-refresh already attempted"): + client._request("GET", RESOURCE_URL) + + assert session.urls == [RESOURCE_URL, WHOAMI_URL, RESOURCE_URL, WHOAMI_URL] + renewal.assert_called_once() + browser.assert_not_called() + + +def test_a_403_after_signing_in_again_is_a_permission_error_if_whoami_is_signed_in( + renewal: MagicMock, +) -> None: + denied = FakeResponse(403) + client, session = _jar_client([FakeResponse(403), FakeResponse(403), denied, _whoami()]) + + with pytest.raises(requests.HTTPError) as raised: + client._request("GET", RESOURCE_URL) + + assert raised.value.response is denied + assert session.urls == [RESOURCE_URL, WHOAMI_URL, RESOURCE_URL, WHOAMI_URL] + renewal.assert_called_once() + + +def test_a_403_after_signing_in_for_a_401_does_not_sign_in_twice( + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(401), FakeResponse(403), FakeResponse(403)]) + + with pytest.raises(SessionExpiredError, match="auto-refresh already attempted"): + client._request("GET", RESOURCE_URL) + + assert session.urls == [RESOURCE_URL, RESOURCE_URL, WHOAMI_URL] + renewal.assert_called_once() + browser.assert_not_called() + + +def test_a_failed_sign_in_after_a_signed_out_403_records_its_category( + renewal: MagicMock, + browser: MagicMock, +) -> None: + renewal.side_effect = RenewalError("code_rejected") + client, session = _jar_client([FakeResponse(403), FakeResponse(403)]) + + with pytest.raises(SessionExpiredError, match="automatic sign-in failed: code_rejected"): + client._request("GET", RESOURCE_URL) + + assert client.renewal_failure == "code_rejected" + assert session.urls == [RESOURCE_URL, WHOAMI_URL] + browser.assert_not_called() + + +def test_a_signed_out_403_on_a_later_page_resumes_that_page_once(renewal: MagicMock) -> None: + second_page = f"{ENROLLMENTS_PAGE}?page=2" + client, session = _jar_client([ + FakeResponse(200, {"Items": [{"id": 1}], "Next": second_page}), + FakeResponse(403), + FakeResponse(403), + FakeResponse(200, {"Items": [{"id": 2}], "Next": None}), + ]) + + assert client.get_enrollments() == [{"id": 1}, {"id": 2}] + + assert session.urls == [ENROLLMENTS_PAGE, second_page, WHOAMI_URL, second_page] + renewal.assert_called_once() + + +def test_a_closed_folder_403_still_reports_the_closed_assignment( + renewal: MagicMock, + browser: MagicMock, +) -> None: + folder = {"Availability": {"EndDateAvailabilityType": 0, "EndDate": "2020-01-01T00:00:00.000Z"}} + client, session = _jar_client([FakeResponse(403), _whoami(), FakeResponse(200, folder)]) + + with pytest.raises(AssignmentClosedError): + AssessmentAPI(client, 12).submissions(34, mine=True) + + assert session.urls[1] == WHOAMI_URL + renewal.assert_not_called() + browser.assert_not_called() + + +@pytest.mark.parametrize( + ("workflow", "error"), + [ + pytest.param(LearnerWorkflow, LearnerWorkflowError, id="learner"), + pytest.param(PreviewWorkflow, PreviewWorkflowError, id="preview"), + ], +) +def test_quiz_account_checks_still_see_their_403( + workflow: type[LearnerWorkflow | PreviewWorkflow], + error: type[Exception], + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(403)]) + + with pytest.raises(error, match=r"HTTP 403"): + object.__new__(workflow)._actor(client) + + assert session.urls == [WHOAMI_URL] + renewal.assert_not_called() + browser.assert_not_called() + + +def test_a_signed_out_403_after_signing_in_for_missing_cookies_is_final( + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(403), FakeResponse(403)], cookies={}) + + with pytest.raises(SessionExpiredError, match="auto-refresh already attempted"): + client._request("GET", RESOURCE_URL) + + assert session.urls == [RESOURCE_URL, WHOAMI_URL] + renewal.assert_called_once() + browser.assert_not_called() + + +@pytest.mark.parametrize("method", ["GET", "HEAD"]) +def test_an_expired_session_signs_in_once_and_replays_once( + method: str, + renewal: MagicMock, + browser: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: + client, session = _jar_client([FakeResponse(401), FakeResponse(200)]) + + assert client._request(method, RESOURCE_URL).status_code == 200 + + assert session.calls == [method, method] + renewal.assert_called_once_with( + observed_cookies=SYNTHETIC_COOKIES, validate=api.probe_cookies, automatic=True, + ) + browser.assert_not_called() + assert client.renewed_session is True + assert client.renewal_failure is None + assert client.cookies == RENEWED_COOKIES + assert _jar(session) == RENEWED_COOKIES + assert client._csrf_token is None + captured = capsys.readouterr() + assert captured.out == "" + assert "Signing in again with the stored authenticator" in captured.err + assert "renewed-sentinel" not in captured.err + + +def test_reusing_another_callers_sign_in_is_not_reported_as_renewed(renewal: MagicMock) -> None: + renewal.return_value = RenewalResult(dict(RENEWED_COOKIES), signed_in=False) + client, _session = _jar_client([FakeResponse(401), FakeResponse(200)]) + + client._request("GET", RESOURCE_URL) + + assert client.renewed_session is False + assert client.cookies == RENEWED_COOKIES + + +@pytest.mark.parametrize("status", [401, 403]) +def test_check_auth_signs_in_again_after_a_signed_out_whoami( + status: int, + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(status), _whoami()]) + + assert client.check_auth() is True + + assert session.calls == ["GET", "GET"] + renewal.assert_called_once() + browser.assert_not_called() + assert client.renewed_session is True + + +def test_check_auth_without_unattended_sign_in_still_refreshes_from_the_browser( + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(403), _whoami()]) + + assert client.check_auth() is True + + assert session.calls == ["GET", "GET"] + browser.assert_called_once_with() + assert client.cookies == BROWSER_COOKIES + assert load_cookies() == BROWSER_COOKIES + assert client.renewed_session is False + + +@pytest.mark.parametrize( + "stored", + [ + pytest.param(None, id="no-cookies-file"), + pytest.param({"d2lSessionVal": "partial"}, id="partial-cookies"), + ], +) +def test_missing_cookies_sign_in_before_the_request_when_configured( + stored: dict[str, str] | None, + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(200)], cookies=stored) + + assert client._request("GET", RESOURCE_URL).status_code == 200 + + renewal.assert_called_once_with( + observed_cookies=stored or {}, validate=api.probe_cookies, automatic=True, + ) + browser.assert_not_called() + assert session.calls == ["GET"] + assert client.renewed_session is True + assert client.cookies == RENEWED_COOKIES + + +def test_missing_cookies_without_unattended_sign_in_fail_before_any_request( + browser: MagicMock, +) -> None: + client, session = _jar_client([], cookies=None) + + with pytest.raises(SessionExpiredError, match="no cookies found"): + client._request("GET", RESOURCE_URL) + + assert session.calls == [] + browser.assert_not_called() + + +def test_missing_cookies_and_no_matching_authenticator_never_open_the_browser( + renewal: MagicMock, + browser: MagicMock, +) -> None: + renewal.side_effect = RenewalUnavailableError() + client, session = _jar_client([], cookies=None) + + with pytest.raises(SessionExpiredError, match="no cookies found"): + client._request("GET", RESOURCE_URL) + + assert session.calls == [] + renewal.assert_called_once() + browser.assert_not_called() + + +def test_expiry_with_no_matching_authenticator_falls_back_to_the_browser( + renewal: MagicMock, + browser: MagicMock, +) -> None: + renewal.side_effect = RenewalUnavailableError() + client, session = _jar_client([FakeResponse(401), FakeResponse(200)]) + + assert client._request("GET", RESOURCE_URL).status_code == 200 + + assert session.calls == ["GET", "GET"] + browser.assert_called_once_with() + assert client.cookies == BROWSER_COOKIES + assert client.renewed_session is False + assert client.renewal_failure is None + + +@pytest.mark.parametrize("category", sorted(RELOGIN_CATEGORIES)) +def test_a_failed_sign_in_records_its_category_and_skips_the_browser( + category: str, + renewal: MagicMock, + browser: MagicMock, +) -> None: + renewal.side_effect = RenewalError(category) + client, session = _jar_client([FakeResponse(401)]) + + with pytest.raises(SessionExpiredError) as raised: + client._request("GET", RESOURCE_URL) + + assert f"automatic sign-in failed: {category}" in str(raised.value) + assert client.renewal_failure == category + assert client.renewed_session is False + assert session.calls == ["GET"] + browser.assert_not_called() + assert client.cookies == SYNTHETIC_COOKIES + + +_NO_RECOVERY_CASES = [ + pytest.param("POST", {}, "default", id="post"), + pytest.param("PUT", {}, "default", id="put"), + pytest.param("DELETE", {}, "default", id="delete"), + pytest.param("GET", {"_replay_safe": False}, "default", id="state-creating-get"), + pytest.param("GET", {}, "read-only", id="read-only-client"), + pytest.param("GET", {}, "override", id="connection-override"), +] + + +def _client_for( + kind: str, + responses: list[FakeResponse | Exception], + monkeypatch: pytest.MonkeyPatch, + cookies: dict[str, str] | None = SYNTHETIC_COOKIES, +) -> tuple[LighthouseClient, JarSession]: + if kind == "override": + # A value-identical copy of the built-in connection still may not sign in. + copy = Connection(LIGHTHOUSE.origin, None) + monkeypatch.setattr(connection, "active_connection", lambda: copy) + return _jar_client(responses, read_only_auth=kind == "read-only", cookies=cookies) + + +@pytest.mark.parametrize(("method", "options", "kind"), _NO_RECOVERY_CASES) +def test_an_expiry_is_not_recovered_when_replay_or_sign_in_is_not_allowed( + method: str, + options: dict[str, object], + kind: str, + monkeypatch: pytest.MonkeyPatch, + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _client_for(kind, [FakeResponse(401)], monkeypatch) + + with pytest.raises(SessionExpiredError): + client._request(method, RESOURCE_URL, **options) + + assert session.calls == [method] + renewal.assert_not_called() + browser.assert_not_called() + + +@pytest.mark.parametrize(("method", "options", "kind"), _NO_RECOVERY_CASES) +def test_missing_cookies_are_not_recovered_when_replay_or_sign_in_is_not_allowed( + method: str, + options: dict[str, object], + kind: str, + monkeypatch: pytest.MonkeyPatch, + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _client_for(kind, [], monkeypatch, cookies={}) + + with pytest.raises(SessionExpiredError, match="no cookies found"): + client._request(method, RESOURCE_URL, **options) + + assert session.calls == [] + renewal.assert_not_called() + browser.assert_not_called() + + +@pytest.mark.parametrize(("method", "options", "kind"), _NO_RECOVERY_CASES) +def test_a_403_is_not_checked_when_replay_or_sign_in_is_not_allowed( + method: str, + options: dict[str, object], + kind: str, + monkeypatch: pytest.MonkeyPatch, + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _client_for(kind, [FakeResponse(403)], monkeypatch) + + with pytest.raises(requests.HTTPError) as raised: + client._request(method, RESOURCE_URL, **options) + + assert raised.value.response.status_code == 403 + assert session.calls == [method] + renewal.assert_not_called() + browser.assert_not_called() + + +def test_a_second_expiry_after_signing_in_does_not_sign_in_again( + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(401), FakeResponse(401)]) + + with pytest.raises(SessionExpiredError, match="auto-refresh already attempted"): + client._request("GET", RESOURCE_URL) + + assert session.calls == ["GET", "GET"] + renewal.assert_called_once() + browser.assert_not_called() + + +def test_signing_in_for_missing_cookies_uses_up_the_single_recovery( + renewal: MagicMock, + browser: MagicMock, +) -> None: + client, session = _jar_client([FakeResponse(401)], cookies={}) + + with pytest.raises(SessionExpiredError, match="auto-refresh already attempted"): + client._request("GET", RESOURCE_URL) + + assert session.calls == ["GET"] + renewal.assert_called_once() + browser.assert_not_called() + + +def test_a_browser_refresh_writes_nothing_while_a_sign_in_holds_the_lock( + monkeypatch: pytest.MonkeyPatch, + browser: MagicMock, +) -> None: + monkeypatch.setattr(session_renewal, "LOCK_TIMEOUT_SECONDS", 0.2) + client, session = _jar_client([FakeResponse(401)]) + + with _sign_in_lock_held_elsewhere(): + with pytest.raises(SessionExpiredError, match="auto-refresh busy"): + client._request("GET", RESOURCE_URL) + + browser.assert_called_once_with() + assert session.calls == ["GET"] + assert not CredentialStore().cookie_file.exists() + assert client.cookies == SYNTHETIC_COOKIES + + +def test_the_legacy_cookie_upgrade_returns_cookies_a_concurrent_sign_in_saved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_legacy_cookies() + held, waiting = threading.Event(), threading.Event() + real_lock = api.renewal_lock + + @contextmanager + def signalling_lock(**kwargs: float | None) -> Iterator[None]: + waiting.set() + with real_lock(**kwargs): + yield + + monkeypatch.setattr(api, "renewal_lock", signalling_lock) + + def sign_in() -> None: + with session_renewal.renewal_lock(): + held.set() + # Write only once the upgrade has seen the legacy file and is + # waiting for the lock. + waiting.wait(5) + save_cookies(dict(RENEWED_COOKIES)) + + thread = threading.Thread(target=sign_in) + thread.start() + try: + assert held.wait(5) + cookies = LighthouseClient().cookies + finally: + waiting.set() + thread.join(5) + + assert cookies == RENEWED_COOKIES + assert not cookie_file_is_legacy() + assert load_cookies() == RENEWED_COOKIES + + +def test_the_legacy_cookie_upgrade_leaves_the_file_untouched_while_busy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original = _write_legacy_cookies() + monkeypatch.setattr(session_renewal, "LOCK_TIMEOUT_SECONDS", 0.2) + client, session = _jar_client([], cookies=None) + + with _sign_in_lock_held_elsewhere(): + with pytest.raises(SessionExpiredError, match="cookie upgrade busy"): + client._request("GET", RESOURCE_URL) + + assert session.calls == [] + assert CredentialStore().cookie_file.read_bytes() == original diff --git a/tests/test_assessment_workflows.py b/tests/test_assessment_workflows.py index 524773c..fb9b2a5 100644 --- a/tests/test_assessment_workflows.py +++ b/tests/test_assessment_workflows.py @@ -5,6 +5,9 @@ import json import subprocess import sys +import threading +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from unittest.mock import Mock, patch @@ -12,7 +15,7 @@ import requests from click.testing import CliRunner -from lighthouse_cli import connection +from lighthouse_cli import connection, session_renewal from lighthouse_cli.api import LighthouseClient, NetworkError, SessionExpiredError from lighthouse_cli.assessment_api import ( AssessmentAPI, @@ -282,6 +285,48 @@ def test_session_import_rejects_another_origin_without_writes(origin): assert not CredentialStore().cookie_file.exists() +@contextmanager +def _sign_in_lock_held_elsewhere(monkeypatch) -> Iterator[None]: + """Hold the sign-in lock from another thread; waiters give up after 0.2 s.""" + monkeypatch.setattr(session_renewal, "LOCK_TIMEOUT_SECONDS", 0.2) + held, release = threading.Event(), threading.Event() + + def hold(): + with session_renewal.renewal_lock(): + held.set() + release.wait(10) + + thread = threading.Thread(target=hold) + thread.start() + try: + assert held.wait(5) + yield + finally: + release.set() + thread.join(5) + + +def test_session_import_writes_nothing_while_a_sign_in_holds_the_lock(monkeypatch): + document = {"origin": "https://lighthouse.manipal.edu", "cookies": dict.fromkeys(COOKIE_NAMES, "SYNTHETIC_SESSION")} + with _sign_in_lock_held_elsewhere(monkeypatch): + result = CliRunner().invoke(cli, ["auth", "import-session", "--json"], input=json.dumps(document)) + assert result.exit_code == 1 + message = "Another sign-in is in progress. Run the command again." + assert json.loads(result.stdout) == {"imported": False, "error": message} + assert message in result.stderr + assert "SYNTHETIC_SESSION" not in result.output + assert not CredentialStore().cookie_file.exists() + + +def test_override_session_import_does_not_wait_for_the_sign_in_lock(sandbox, monkeypatch): + document = {"origin": SANDBOX_ORIGIN, "cookies": dict.fromkeys(COOKIE_NAMES, "SYNTHETIC_SESSION")} + with _sign_in_lock_held_elsewhere(monkeypatch): + result = CliRunner().invoke(cli, ["auth", "import-session", "--json"], input=json.dumps(document)) + assert result.exit_code == 0 + assert json.loads(result.stdout)["imported"] is True + assert CredentialStore(config_dir=sandbox.cookie_dir).cookie_file.exists() + + def test_session_import_no_longer_accepts_a_site_option(): result = CliRunner().invoke(cli, ["auth", "import-session", "--site", "lighthouse", "--json"], input="{}") assert result.exit_code != 0 diff --git a/tests/test_auth_login.py b/tests/test_auth_login.py index 22c8310..c33303e 100644 --- a/tests/test_auth_login.py +++ b/tests/test_auth_login.py @@ -7,6 +7,7 @@ from __future__ import annotations +import io import json import threading import urllib.parse @@ -20,6 +21,7 @@ from click.testing import CliRunner from lighthouse_cli import auth as auth_mod +from lighthouse_cli import session_renewal from lighthouse_cli.auth import ( normalize_totp, plan_login, @@ -28,9 +30,12 @@ ) 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.credential_store import CredentialStore, CredentialStoreError from lighthouse_cli.ms_auth import MicrosoftSSOError +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 +from lighthouse_cli.totp import parse_totp_secret # --------------------------------------------------------------------------- # Shared helpers @@ -98,12 +103,12 @@ def creds_env(isolated_config: Path, monkeypatch: pytest.MonkeyPatch) -> Path: @contextmanager def _mock_sso( - check_auth: bool = True, + session_valid: bool = True, login_side_effect: BaseException | None = None, ) -> Iterator[tuple[MagicMock, MagicMock]]: - """Mock MicrosoftSSOClient and LighthouseClient inside lighthouse_cli.auth. + """Mock MicrosoftSSOClient and the session probe inside lighthouse_cli.auth. - Yields ``(sso_mock, client_mock)``; ``sso.login`` returns valid cookies + Yields ``(sso_mock, probe_mock)``; ``sso.login`` returns valid cookies unless ``login_side_effect`` is given. """ sso = MagicMock() @@ -111,9 +116,10 @@ def _mock_sso( if login_side_effect is not None: sso.login.side_effect = login_side_effect with patch.object(auth_mod, "MicrosoftSSOClient", return_value=sso): - with patch.object(auth_mod, "LighthouseClient") as client_cls: - client_cls.return_value.check_auth.return_value = check_auth - yield sso, client_cls.return_value + with patch.object( + auth_mod, "probe_cookies", return_value="valid" if session_valid else "invalid", + ) as probe: + yield sso, probe def _invoke_login( @@ -296,17 +302,17 @@ def test_plan_login( def _patch_tail( monkeypatch: pytest.MonkeyPatch, check_ok: bool = True, ) -> tuple[list[str], MagicMock, MagicMock]: - """Stub the tail's collaborators; return (call order, client factory, store).""" + """Stub the tail's collaborators; return (call order, session probe, store).""" order: list[str] = [] monkeypatch.setattr(auth_mod, "save_cookies", lambda cookies: order.append("cookies")) - client = MagicMock() - client.check_auth.side_effect = lambda: order.append("check") or check_ok - client_factory = MagicMock(return_value=client) - monkeypatch.setattr(auth_mod, "LighthouseClient", client_factory) + probe = MagicMock( + side_effect=lambda cookies: order.append("check") or ("valid" if check_ok else "invalid"), + ) + monkeypatch.setattr(auth_mod, "probe_cookies", probe) store = MagicMock() store.save.side_effect = lambda u, p: order.append("creds") monkeypatch.setattr(auth_mod, "CredentialStore", lambda: store) - return order, client_factory, store + return order, probe, store @pytest.mark.parametrize( @@ -321,23 +327,25 @@ def test_tail_orders_cookies_before_check_before_credential_save( monkeypatch: pytest.MonkeyPatch, pair: tuple[str, str] | None, expected_order: list[str], ) -> None: """Security ordering: seal cookies → validate session → save credentials.""" - order, client_factory, _store = _patch_tail(monkeypatch) + order, probe, _store = _patch_tail(monkeypatch) + cookies = _make_d2l_cookies() rc = auth_mod._persist_check_report( - _make_d2l_cookies(), json_output=True, + cookies, json_output=True, failure_hint="Try: lighthouse auth login", save_credentials_pair=pair, ) assert rc == 0 assert order == expected_order - client_factory.assert_called_once_with(read_only_auth=True) + # The candidate cookies themselves are probed, not whatever is on disk. + probe.assert_called_once_with(cookies) def test_tail_failed_session_check_saves_nothing( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - """check_auth failure reports an error and never stores credentials.""" - _order, _factory, store = _patch_tail(monkeypatch, check_ok=False) + """A failed session probe reports an error and never stores credentials.""" + _order, _probe, store = _patch_tail(monkeypatch, check_ok=False) rc = auth_mod._persist_check_report( _make_d2l_cookies(), json_output=True, @@ -463,15 +471,15 @@ def test_login_credential_sources( # --------------------------------------------------------------------------- def test_totp_flag_submits_code_and_verifies_session(cli_runner: CliRunner, creds_env: Path) -> None: - """--totp submits the 2FA code without prompting; check_auth() then confirms - the session is valid.""" - with _mock_sso() as (sso, client): + """--totp submits the 2FA code without prompting; the session probe then + confirms the session is valid.""" + with _mock_sso() as (sso, probe): result = _invoke_login(cli_runner, ["--totp", "123456"]) assert result.exit_code == 0 sso.login.assert_called_once() assert sso.login.call_args.args[2] == "123456" - client.check_auth.assert_called_once() + probe.assert_called_once_with(_make_d2l_cookies()) def test_explicit_app_method_ignores_stale_sms_pending(cli_runner: CliRunner, creds_env: Path) -> None: @@ -575,13 +583,13 @@ def test_auth_status_works_after_login(cli_runner: CliRunner, isolated_config: P (isolated_config / "cookies.json").write_text(json.dumps(cookies)) with patch("lighthouse_cli.commands.LighthouseClient") as mock_commands: - with patch.object(auth_mod, "LighthouseClient") as mock_auth: - mock_client = MagicMock() - mock_client.check_auth.return_value = True - mock_client.cookies = cookies - mock_commands.return_value = mock_client - mock_auth.return_value = mock_client - result = cli_runner.invoke(cli, ["auth", "status"], catch_exceptions=False) + mock_client = MagicMock() + mock_client.check_auth.return_value = True + mock_client.cookies = cookies + mock_client.renewed_session = False + mock_client.renewal_failure = None + mock_commands.return_value = mock_client + result = cli_runner.invoke(cli, ["auth", "status"], catch_exceptions=False) assert result.exit_code == 0 assert "Session valid" in result.output or "valid" in result.output @@ -668,8 +676,8 @@ def test_unexpected_error_never_leaks_exception_text(cli_runner: CliRunner, cred def test_unexpected_failure_wrapped_cleanly(cli_runner: CliRunner, creds_env: Path) -> None: """An unexpected exception exits cleanly under --json — never a traceback.""" - with _mock_sso() as (_sso, client): - client.check_auth.side_effect = RuntimeError("kaboom") + with _mock_sso() as (_sso, probe): + probe.side_effect = RuntimeError("kaboom") result = _invoke_login(cli_runner, ["--totp", "123456", "--json"]) assert result.exit_code == 1 @@ -827,7 +835,7 @@ def test_keyring_failure_is_clean_under_json( def test_failed_validation_never_saves_credentials(cli_runner: CliRunner, creds_env: Path) -> None: """--save-credentials with a failed session check stores nothing.""" - with _mock_sso(check_auth=False): + with _mock_sso(session_valid=False): result = _invoke_login( cli_runner, ["--totp", "123456", "--save-credentials", "--json"], ) @@ -849,8 +857,7 @@ def test_verify_never_saves_credentials( with patch.object(auth_mod, "MicrosoftSSOClient") as sso_cls: sso_cls.return_value.complete_mfa_pending.return_value = _make_d2l_cookies() with patch.object(auth_mod, "CredentialStore", store_cls): - with patch.object(auth_mod, "LighthouseClient") as client_cls: - client_cls.return_value.check_auth.return_value = True + with patch.object(auth_mod, "probe_cookies", return_value="valid"): result = cli_runner.invoke( cli, ["auth", "verify", "123456", "--json"], catch_exceptions=False, @@ -1389,3 +1396,371 @@ def test_login_accepts_call_and_push_choices(self, cli_runner: CliRunner, method catch_exceptions=False, ) assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Stored authenticator (unattended sign-in) +# --------------------------------------------------------------------------- + +AUTHENTICATOR_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" # pragma: allowlist secret +STORED_PASSWORD = "stored-password-sentinel" # pragma: allowlist secret +STORED_LOGIN_NOTICE = "Using the stored authenticator" + + +class _UnreadableStdin(io.BytesIO): + """A stdin that fails the test when read (Click probes it with ``read(0)``).""" + + def read(self, size: int | None = -1) -> bytes: + if size == 0: + return b"" + raise AssertionError("stdin was read") + + def read1(self, size: int = -1) -> bytes: + raise AssertionError("stdin was read") + + def readline(self, size: int | None = -1) -> bytes: + raise AssertionError("stdin was read") + + def readinto(self, buffer: Any) -> int: + raise AssertionError("stdin was read") + + +def _provider_login(username: str, password: str, **kwargs: Any) -> dict[str, str]: + """Answer MFA through the provider once, like the real PhoneAppOTP path.""" + kwargs["totp_provider"]() + return _make_d2l_cookies() + + +@pytest.fixture +def stored_authenticator( + creds_env: Path, monkeypatch: pytest.MonkeyPatch, +) -> tuple[MagicMock, MagicMock]: + """Saved credentials plus an authenticator bound to the env username. + + Returns ``(sso, probe)``: the fake SSO client the stored path signs in + with, and the session probe (always ``valid``). + """ + store = CredentialStore() + store.save("user@manipal.edu", STORED_PASSWORD) + store.save_totp("User@Manipal.edu", parse_totp_secret(AUTHENTICATOR_SECRET), last_step=None) + sso = MagicMock() + sso.login.side_effect = _provider_login + monkeypatch.setattr(session_renewal, "MicrosoftSSOClient", lambda: sso) + probe = MagicMock(return_value="valid") + monkeypatch.setattr(auth_mod, "probe_cookies", probe) + monkeypatch.delenv("LIGHTHOUSE_MFA_METHOD", raising=False) + return sso, probe + + +@pytest.mark.parametrize( + ("explicit_method", "totp_code", "totp_stdin", "expected"), + [ + pytest.param(None, None, False, True, id="method-unset"), + pytest.param("app", None, False, True, id="app"), + *[ + pytest.param(method, None, False, False, id=method) + for method in VALID_MFA_METHODS if method != "app" + ], + pytest.param(None, "123456", False, False, id="literal-totp"), + pytest.param("app", "123456", False, False, id="app-literal-totp"), + pytest.param(None, None, True, False, id="totp-stdin"), + ], +) +def test_use_stored_authenticator_policy( + explicit_method: str | None, totp_code: str | None, totp_stdin: bool, expected: bool, +) -> None: + assert auth_mod.use_stored_authenticator( + explicit_method=explicit_method, totp_code=totp_code, totp_stdin=totp_stdin, + ) is expected + + +@pytest.mark.parametrize( + ("args", "env_method"), + [ + pytest.param([], None, id="method-unset"), + pytest.param(["--mfa-method", "app"], None, id="flag-app"), + pytest.param([], "app", id="env-app"), + ], +) +def test_stored_authenticator_signs_in_without_reading_stdin( + cli_runner: CliRunner, + stored_authenticator: tuple[MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, + args: list[str], + env_method: str | None, +) -> None: + sso, probe = stored_authenticator + if env_method is not None: + monkeypatch.setenv("LIGHTHOUSE_MFA_METHOD", env_method) + + with patch.object(auth_mod, "MicrosoftSSOClient") as human_sso: + result = cli_runner.invoke( + cli, ["auth", "login", *args, "--json"], + input=_UnreadableStdin(), catch_exceptions=False, + ) + + assert result.exit_code == 0, result.stderr + # json.loads rejects a second document, so this is the only one. + assert json.loads(result.stdout) == {"success": True, "cookies": list(auth_mod.COOKIE_NAMES)} + assert STORED_LOGIN_NOTICE in result.stderr + human_sso.assert_not_called() + sso.login.assert_called_once() + # The password resolved for this run (env), not the stored one. + assert sso.login.call_args.args == ("user@manipal.edu", "secret") + assert sso.login.call_args.kwargs["mfa_method"] == "app" + probe.assert_called_once_with(_make_d2l_cookies()) + assert load_cookies() == _make_d2l_cookies() + + +@pytest.mark.parametrize( + "args", + [ + *[ + pytest.param(["--mfa-method", method], id=method) + for method in VALID_MFA_METHODS if method != "app" + ], + pytest.param(["--totp", "123456"], id="literal-totp"), + pytest.param(["--mfa-method", "app", "--totp", "123456"], id="app-literal-totp"), + ], +) +def test_explicit_method_or_code_keeps_the_person_driven_flow( + cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], args: list[str], +) -> None: + sso, _probe = stored_authenticator + + with _mock_sso(): + result = _invoke_login(cli_runner, [*args, "--json"]) + + json.loads(result.stdout) + sso.login.assert_not_called() + assert STORED_LOGIN_NOTICE not in result.stderr + + +def test_env_method_other_than_app_keeps_the_person_driven_flow( + cli_runner: CliRunner, + stored_authenticator: tuple[MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, +) -> None: + sso, _probe = stored_authenticator + monkeypatch.setenv("LIGHTHOUSE_MFA_METHOD", "sms") + + with _mock_sso(): + result = _invoke_login(cli_runner, ["--json"]) + + json.loads(result.stdout) + sso.login.assert_not_called() + + +def test_username_mismatch_keeps_the_person_driven_flow( + cli_runner: CliRunner, + stored_authenticator: tuple[MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, +) -> None: + sso, _probe = stored_authenticator + monkeypatch.setenv("LIGHTHOUSE_USERNAME", "someone.else@manipal.edu") + + with _mock_sso() as (human_sso, _human_probe): + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 0 + human_sso.login.assert_called_once() + sso.login.assert_not_called() + + +def test_unreadable_stored_authenticator_warns_and_keeps_the_person_driven_flow( + cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], +) -> None: + sso, _probe = stored_authenticator + CredentialStore().authenticator_file.write_text("{not json") + + with _mock_sso() as (human_sso, _human_probe): + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 0 + assert "ignoring an unreadable stored authenticator" in result.stderr + human_sso.login.assert_called_once() + sso.login.assert_not_called() + + +def test_valid_cookies_on_disk_do_not_skip_an_explicit_login( + cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], +) -> None: + sso, _probe = stored_authenticator + save_cookies({**_make_d2l_cookies(), "d2lSessionVal": "older-session"}) + + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 0 + sso.login.assert_called_once() + assert load_cookies() == _make_d2l_cookies() + + +def test_stored_login_saves_cookies_once_and_skips_the_person_tail( + cli_runner: CliRunner, + stored_authenticator: tuple[MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, +) -> None: + saved: list[dict[str, str]] = [] + real_save = session_renewal.save_cookies + + def counting_save(cookies: dict[str, str]) -> None: + saved.append(dict(cookies)) + real_save(cookies) + + monkeypatch.setattr(session_renewal, "save_cookies", counting_save) + monkeypatch.setattr(auth_mod, "save_cookies", MagicMock(side_effect=AssertionError("person tail ran"))) + + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 0 + assert saved == [_make_d2l_cookies()] + stored_authenticator[1].assert_called_once_with(_make_d2l_cookies()) + + +def test_stored_login_records_a_failure_and_a_later_success_clears_it( + cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], creds_env: Path, +) -> None: + sso, _probe = stored_authenticator + state_file = creds_env / "relogin_state.json" + sso.login.side_effect = MicrosoftSSOError( + "rejected", step="POST credentials", category="credentials_rejected", + ) + + failed = _invoke_login(cli_runner, ["--json"]) + + assert failed.exit_code == 1 + assert json.loads(failed.stdout) == { + "success": False, "error": auth_mod._STORED_LOGIN_FAILURES["credentials_rejected"], + } + state = json.loads(state_file.read_text()) + assert (state["blocked"], state["category"]) == (True, "credentials_rejected") + + # An explicit login ignores the breaker, and success clears it. + sso.login.side_effect = _provider_login + succeeded = _invoke_login(cli_runner, ["--json"]) + + assert succeeded.exit_code == 0 + assert not state_file.exists() + + +def test_every_category_has_a_stored_login_message() -> None: + assert set(auth_mod._STORED_LOGIN_FAILURES) == RELOGIN_CATEGORIES + + +@pytest.mark.parametrize("category", sorted(RELOGIN_CATEGORIES)) +def test_stored_login_failure_renders_one_fixed_document( + cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], category: str, +) -> None: + message = auth_mod._STORED_LOGIN_FAILURES[category] + + with patch.object(auth_mod, "renew_session", side_effect=RenewalError(category)): + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": message} + assert f"Error: {message}" in result.stderr + + +def test_stored_login_without_a_matching_authenticator_reports_setup( + cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], +) -> None: + with patch.object(auth_mod, "renew_session", side_effect=RenewalUnavailableError()): + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": str(RenewalUnavailableError())} + + +def test_stored_login_clears_a_pending_two_step_login( + cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], creds_env: Path, +) -> None: + save_mfa_pending({"mfa_method": "sms", "created_at": "2026-08-27T00:00:00Z"}) + assert (creds_env / "mfa_pending.json").exists() + + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 0 + assert not (creds_env / "mfa_pending.json").exists() + + +@pytest.mark.parametrize( + ("args", "expected"), + [ + pytest.param(["--save-credentials"], ("user@manipal.edu", "secret"), id="save"), + pytest.param([], ("user@manipal.edu", STORED_PASSWORD), id="no-save"), + ], +) +def test_stored_login_saves_credentials_only_when_asked( + cli_runner: CliRunner, + stored_authenticator: tuple[MagicMock, MagicMock], + args: list[str], + expected: tuple[str, str], +) -> None: + result = _invoke_login(cli_runner, [*args, "--json"]) + + assert result.exit_code == 0 + assert CredentialStore().load() == expected + + +def test_tail_holds_the_sign_in_lock_while_it_checks_its_own_cookies( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + """A concurrent automatic sign-in cannot publish cookies mid-tail.""" + candidate = _make_d2l_cookies() + other_attempts: list[str] = [] + + def other_sign_in() -> None: + try: + with renewal_lock(timeout=0.2): + other_attempts.append("acquired") + except RenewalError as exc: + other_attempts.append(exc.category) + + def probe(cookies: dict[str, str]) -> str: + worker = threading.Thread(target=other_sign_in) + worker.start() + worker.join(10) + assert cookies == candidate + return "valid" + + monkeypatch.setattr(auth_mod, "probe_cookies", probe) + + rc = auth_mod._persist_check_report(candidate, json_output=True) + + assert rc == 0 + assert other_attempts == ["busy"] + assert load_cookies() == candidate + assert json.loads(capsys.readouterr().out)["success"] is True + + +def test_tail_busy_saves_no_cookies_and_no_credentials( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + probe = MagicMock(return_value="valid") + monkeypatch.setattr(auth_mod, "probe_cookies", probe) + store = MagicMock() + monkeypatch.setattr(auth_mod, "CredentialStore", lambda: store) + monkeypatch.setattr(session_renewal, "LOCK_TIMEOUT_SECONDS", 0.2) + held, release = threading.Event(), threading.Event() + + def holder() -> None: + with renewal_lock(): + held.set() + release.wait(10) + + worker = threading.Thread(target=holder) + worker.start() + assert held.wait(10) + try: + rc = auth_mod._persist_check_report( + _make_d2l_cookies(), json_output=True, save_credentials_pair=("u", "p"), + ) + finally: + release.set() + worker.join(10) + + assert rc == 1 + assert json.loads(capsys.readouterr().out) == {"success": False, "error": auth_mod._SIGN_IN_BUSY} + assert not CredentialStore().cookie_file.exists() + probe.assert_not_called() + store.save.assert_not_called() diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py new file mode 100644 index 0000000..96be9d4 --- /dev/null +++ b/tests/test_auth_totp.py @@ -0,0 +1,345 @@ +"""Tests for ``auth totp-setup`` / ``auth totp-remove`` (the stored authenticator). + +Secrets arrive on stdin or a hidden prompt, are bound to the saved username, +and never reach stdout, stderr or an error message. +""" + +from __future__ import annotations + +import getpass +import io +import json +import sys +import threading +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from click.testing import CliRunner + +from lighthouse_cli import auth as auth_mod +from lighthouse_cli import session_renewal +from lighthouse_cli.cli import cli +from lighthouse_cli.config import save_cookies +from lighthouse_cli.credential_store import CredentialStore +from lighthouse_cli.session_renewal import renewal_lock +from lighthouse_cli.totp import SECRET_ERROR_MESSAGES, parse_totp_secret, time_step + +USERNAME = "student@example.edu" +PASSWORD = "pw-sentinel-Q7" # pragma: allowlist secret +SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" # pragma: allowlist secret +OTHER_SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" # pragma: allowlist secret +URI = f"otpauth://totp/Microsoft:{USERNAME}?secret={SECRET}&issuer=Microsoft" +T0 = 1_800_000_015.0 # 15 s into a 30 s step +STEP0 = int(T0 // 30) + + +class _UnreadableStdin(io.BytesIO): + """A stdin that fails the test when read (Click probes it with ``read(0)``).""" + + def read(self, size: int | None = -1) -> bytes: + if size == 0: + return b"" + raise AssertionError("stdin was read") + + def read1(self, size: int = -1) -> bytes: + raise AssertionError("stdin was read") + + def readline(self, size: int | None = -1) -> bytes: + raise AssertionError("stdin was read") + + def readinto(self, buffer: Any) -> int: + raise AssertionError("stdin was read") + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + d = tmp_path / "cfg" + d.mkdir(mode=0o700) + monkeypatch.setenv("LIGHTHOUSE_CONFIG_DIR", str(d)) + monkeypatch.setattr(session_renewal, "_clock", lambda: T0) + return d + + +@pytest.fixture +def saved(config_dir: Path) -> CredentialStore: + store = CredentialStore() + store.save(USERNAME, PASSWORD) + return store + + +def _setup(runner: CliRunner, stdin: str | io.BytesIO, *args: str) -> Any: + return runner.invoke(cli, ["auth", "totp-setup", *args], input=stdin, catch_exceptions=False) + + +def _assert_no_secret(result: Any) -> None: + output = result.stdout + result.stderr + for sentinel in (SECRET, OTHER_SECRET, PASSWORD, "JBSWY3DP"): + assert sentinel not in output + + +# --------------------------------------------------------------------------- +# totp-setup +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("stdin", [f"{SECRET}\n", f"{URI}\n", "jbsw y3dp ehpk 3pxp jbsw y3dp ehpk 3pxp\n"]) +def test_setup_from_stdin_binds_the_secret_to_the_saved_username( + runner: CliRunner, saved: CredentialStore, stdin: str, +) -> None: + result = _setup(runner, stdin, "--json") + + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == {"success": True, "configured": True} + stored = saved.load_totp() + assert stored is not None + assert stored.username == USERNAME + assert stored.params == parse_totp_secret(SECRET) + _assert_no_secret(result) + + +def test_setup_human_output_names_the_next_step(runner: CliRunner, saved: CredentialStore) -> None: + result = _setup(runner, f"{SECRET}\n") + + assert result.exit_code == 0 + assert result.stdout == "Authenticator secret stored. Next: lighthouse auth login\n" + _assert_no_secret(result) + + +def test_setup_reserves_the_current_step(runner: CliRunner, saved: CredentialStore) -> None: + """The code typed while registering the app is never sent again.""" + assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 + + stored = saved.load_totp() + assert stored is not None + assert stored.last_step == STEP0 == time_step(T0, 30) + + +def test_setup_with_the_same_key_keeps_a_newer_step(runner: CliRunner, saved: CredentialStore) -> None: + saved.save_totp(USERNAME.upper(), parse_totp_secret(SECRET), last_step=STEP0 + 5) + + assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 + + stored = saved.load_totp() + assert stored is not None + assert stored.last_step == STEP0 + 5 + + +def test_setup_with_a_new_key_starts_from_the_current_step(runner: CliRunner, saved: CredentialStore) -> None: + saved.save_totp(USERNAME, parse_totp_secret(OTHER_SECRET), last_step=STEP0 + 5) + + assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 + + stored = saved.load_totp() + assert stored is not None + assert stored.params == parse_totp_secret(SECRET) + assert stored.last_step == STEP0 + + +def test_setup_clears_the_breaker(runner: CliRunner, saved: CredentialStore, config_dir: Path) -> None: + state = config_dir / "relogin_state.json" + state.write_text(json.dumps({"v": 1, "blocked": True, "category": "code_rejected"})) + + assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 + + assert not state.exists() + + +def test_setup_on_a_terminal_reads_a_hidden_prompt( + runner: CliRunner, saved: CredentialStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + prompts: list[tuple[str, bool]] = [] + + def hidden(prompt: str, stream: Any = None) -> str: + prompts.append((prompt, stream is sys.stderr)) + return SECRET + + monkeypatch.setattr(auth_mod, "_is_interactive", lambda: True) + monkeypatch.setattr(getpass, "getpass", hidden) + + result = _setup(runner, _UnreadableStdin(), "--json") + + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout) == {"success": True, "configured": True} + assert "input is hidden" in result.stderr + # The prompt goes to stderr, keeping stdout JSON-only. + assert prompts == [("Secret: ", True)] + assert saved.load_totp() is not None + + +def test_setup_refuses_echoed_input_before_reading_anything( + runner: CliRunner, saved: CredentialStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """``getpass`` warns and then echoes when it cannot hide input; that + warning becomes the fixed error before stdin is touched.""" + monkeypatch.setattr(auth_mod, "_is_interactive", lambda: True) + monkeypatch.setattr(getpass, "getpass", getpass.fallback_getpass) + + result = _setup(runner, _UnreadableStdin(), "--json") + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": auth_mod._HIDDEN_INPUT_UNAVAILABLE} + assert saved.load_totp() is None + + +def test_setup_terminal_eof_reports_an_empty_secret( + runner: CliRunner, saved: CredentialStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth_mod, "_is_interactive", lambda: True) + monkeypatch.setattr(getpass, "getpass", MagicMock(side_effect=EOFError)) + + result = _setup(runner, _UnreadableStdin(), "--json") + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": "The authenticator secret is empty."} + + +@pytest.mark.parametrize( + "stdin", + [ + "NOT-A-SECRET-SENTINEL!!\n", + "\n", + "JBSWY3DP\n", + f"otpauth://hotp/x?secret={SECRET}&counter=1\n", + f"otpauth://totp/x?secret={SECRET}&algorithm=SHA256\n", + f"otpauth://totp/x?secret={SECRET}&secret={OTHER_SECRET}\n", + ], +) +def test_invalid_secret_gets_a_fixed_error_that_never_echoes_it( + runner: CliRunner, saved: CredentialStore, stdin: str, +) -> None: + result = _setup(runner, stdin, "--json") + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["success"] is False + assert payload["error"] in SECRET_ERROR_MESSAGES + assert "SENTINEL" not in result.stdout + result.stderr + _assert_no_secret(result) + assert saved.load_totp() is None + + +def test_setup_without_saved_credentials_reads_nothing(runner: CliRunner, config_dir: Path) -> None: + result = _setup(runner, _UnreadableStdin(), "--json") + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": auth_mod._NO_SAVED_CREDENTIALS} + assert not (config_dir / "authenticator.json").exists() + + +def test_setup_busy_stores_nothing( + runner: CliRunner, saved: CredentialStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(session_renewal, "LOCK_TIMEOUT_SECONDS", 0.2) + held, release = threading.Event(), threading.Event() + + def holder() -> None: + with renewal_lock(): + held.set() + release.wait(10) + + worker = threading.Thread(target=holder) + worker.start() + assert held.wait(10) + try: + result = _setup(runner, f"{SECRET}\n", "--json") + finally: + release.set() + worker.join(10) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": auth_mod._SIGN_IN_BUSY} + assert saved.load_totp() is None + + +def test_setup_without_a_key_source_fails_before_reading( + runner: CliRunner, config_dir: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("LIGHTHOUSE_SECRETS_PASSPHRASE", raising=False) + monkeypatch.setattr("lighthouse_cli.credential_store._load_keyring_module", lambda: None) + + result = _setup(runner, _UnreadableStdin(), "--json") + + assert result.exit_code == 1 + assert "LIGHTHOUSE_SECRETS_PASSPHRASE" in json.loads(result.stdout)["error"] + + +# --------------------------------------------------------------------------- +# totp-remove +# --------------------------------------------------------------------------- + + +def test_remove_deletes_only_the_authenticator( + runner: CliRunner, saved: CredentialStore, config_dir: Path, +) -> None: + saved.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=STEP0) + save_cookies({"d2lSessionVal": "s", "d2lSecureSessionVal": "x"}) + (config_dir / "relogin_state.json").write_text(json.dumps({"v": 1, "blocked": True})) + + first = runner.invoke(cli, ["auth", "totp-remove", "--json"], catch_exceptions=False) + second = runner.invoke(cli, ["auth", "totp-remove", "--json"], catch_exceptions=False) + + assert (first.exit_code, json.loads(first.stdout)) == (0, {"success": True, "removed": True}) + assert (second.exit_code, json.loads(second.stdout)) == (0, {"success": True, "removed": False}) + assert saved.load_totp() is None + assert saved.load() == (USERNAME, PASSWORD) + assert saved.cookie_file.exists() + assert not (config_dir / "relogin_state.json").exists() + + +def test_remove_human_output(runner: CliRunner, saved: CredentialStore) -> None: + saved.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=None) + + first = runner.invoke(cli, ["auth", "totp-remove"], catch_exceptions=False) + second = runner.invoke(cli, ["auth", "totp-remove"], catch_exceptions=False) + + assert first.stdout == "Stored authenticator removed.\n" + assert second.stdout == "No stored authenticator was configured.\n" + + +def test_remove_busy_keeps_the_authenticator( + runner: CliRunner, saved: CredentialStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + saved.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=None) + monkeypatch.setattr(session_renewal, "LOCK_TIMEOUT_SECONDS", 0.2) + held, release = threading.Event(), threading.Event() + + def holder() -> None: + with renewal_lock(): + held.set() + release.wait(10) + + worker = threading.Thread(target=holder) + worker.start() + assert held.wait(10) + try: + result = runner.invoke(cli, ["auth", "totp-remove", "--json"], catch_exceptions=False) + finally: + release.set() + worker.join(10) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": auth_mod._SIGN_IN_BUSY} + assert saved.load_totp() is not None + + +def test_auth_help_lists_the_authenticator_commands(runner: CliRunner) -> None: + result = runner.invoke(cli, ["auth", "--help"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "totp-setup" in result.stdout + assert "totp-remove" in result.stdout + + +def test_setup_help_discloses_the_trade_off(runner: CliRunner) -> None: + result = runner.invoke(cli, ["auth", "totp-setup", "--help"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "Trade-off" in result.stdout + assert "never accepted as a command-line argument" in " ".join(result.stdout.split()) diff --git a/tests/test_cli_contracts.py b/tests/test_cli_contracts.py index f6cb82c..65db60f 100644 --- a/tests/test_cli_contracts.py +++ b/tests/test_cli_contracts.py @@ -5,23 +5,28 @@ import io import json import math +from collections.abc import Callable from contextlib import ExitStack from pathlib import Path +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest import requests from click.testing import CliRunner -from lighthouse_cli.api import LighthouseClient, NetworkError +from lighthouse_cli import display +from lighthouse_cli.api import LighthouseClient, NetworkError, _session_expired from lighthouse_cli.cli import cli from lighthouse_cli.commands import _resolve_course_scope +from lighthouse_cli.config import COOKIE_NAMES, save_cookies from lighthouse_cli.display import ( _has_json_option, format_user_error, output_json, safe_display_text, ) +from lighthouse_cli.ms_errors import RELOGIN_CATEGORIES from lighthouse_cli.utils import get_course_name, get_enrolled_course_catalog @@ -381,6 +386,8 @@ def test_auth_status_invalid_json_and_human_paths_fail_safely() -> None: client = Mock() client.cookies = {"d2lSecureSessionVal": sentinel} client.check_auth.return_value = False + client.renewed_session = False + client.renewal_failure = None with patch("lighthouse_cli.commands.LighthouseClient", return_value=client): result = CliRunner().invoke(cli, ["auth", "status", "--json"]) @@ -390,6 +397,9 @@ def test_auth_status_invalid_json_and_human_paths_fail_safely() -> None: assert json.loads(result.stdout) == { "valid": False, "cookies": ["d2lSecureSessionVal"], + "renewed": False, + "category": None, + "unattended_login": {"configured": False, "blocked": False}, "error": "Session expired. Run: lighthouse auth login", } assert sentinel not in result.stdout + result.stderr @@ -397,6 +407,129 @@ def test_auth_status_invalid_json_and_human_paths_fail_safely() -> None: assert "Error: Session expired. Run: lighthouse auth login" in human.stderr +_CONFIGURED = {"configured": True, "blocked": False} + + +def _status_client(cookies: dict[str, str]) -> Mock: + client = Mock() + client.cookies = cookies + client.renewed_session = False + client.renewal_failure = None + return client + + +def _invoke_status( + make_client: Callable[[], Mock], unattended: dict[str, bool], +) -> tuple[Any, Any]: + """Run ``auth status --json`` and the human form, each on a fresh client.""" + with patch("lighthouse_cli.commands.LighthouseClient", side_effect=make_client), \ + patch("lighthouse_cli.commands.unattended_status", return_value=unattended): + return ( + CliRunner().invoke(cli, ["auth", "status", "--json"]), + CliRunner().invoke(cli, ["auth", "status"]), + ) + + +def test_auth_status_signs_in_for_missing_cookies_and_reports_the_renewal() -> None: + sentinel = "COOKIE_VALUE_SENTINEL" + + def make_client() -> Mock: + client = _status_client({}) + + def check_auth() -> bool: + client.cookies = {"d2lSecureSessionVal": sentinel} + client.renewed_session = True + return True + + client.check_auth.side_effect = check_auth + return client + + result, human = _invoke_status(make_client, _CONFIGURED) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "valid": True, + "cookies": ["d2lSecureSessionVal"], + "renewed": True, + "unattended_login": _CONFIGURED, + } + assert human.exit_code == 0 + assert human.stdout.startswith("Session renewed.\nSession valid. Cookies: d2lSecureSessionVal") + assert sentinel not in result.stdout + result.stderr + human.stdout + human.stderr + + +def test_auth_status_reports_the_automatic_sign_in_failure_category() -> None: + sentinel = "COOKIE_VALUE_SENTINEL" + blocked = {"configured": True, "blocked": True} + message = "Session expired; automatic sign-in failed (code_rejected). Run: lighthouse auth login" + + def make_client() -> Mock: + client = _status_client({"d2lSecureSessionVal": sentinel}) + + def check_auth() -> bool: + client.renewal_failure = "code_rejected" + return False + + client.check_auth.side_effect = check_auth + return client + + result, human = _invoke_status(make_client, blocked) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "valid": False, + "cookies": ["d2lSecureSessionVal"], + "renewed": False, + "category": "code_rejected", + "unattended_login": blocked, + "error": message, + } + assert human.exit_code == 1 + assert human.stdout == "" + assert f"Error: {message}" in human.stderr + assert sentinel not in result.stdout + result.stderr + human.stderr + + +def test_auth_status_without_cookies_or_unattended_sign_in_does_not_probe() -> None: + clients: list[Mock] = [] + + def make_client() -> Mock: + clients.append(_status_client({})) + return clients[-1] + + unconfigured = {"configured": False, "blocked": False} + result, _human = _invoke_status(make_client, unconfigured) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "valid": False, + "unattended_login": unconfigured, + "error": "No cookies found. Run: lighthouse auth login", + } + for client in clients: + client.check_auth.assert_not_called() + + +def test_auth_status_transport_failure_keeps_the_renewal_fields() -> None: + error = NetworkError("Network request failed after 4 attempt(s).") + + def make_client() -> Mock: + client = _status_client({"d2lSecureSessionVal": "COOKIE_VALUE_SENTINEL"}) + client.check_auth.side_effect = error + return client + + result, _human = _invoke_status(make_client, _CONFIGURED) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "valid": False, + "renewed": False, + "category": None, + "unattended_login": _CONFIGURED, + "error": format_user_error(error), + } + + def test_semesters_normalize_malformed_records_for_human_and_json() -> None: sentinel = "SEMESTER_SECRET_SENTINEL" records = [ @@ -464,6 +597,31 @@ def test_human_usage_errors_stay_on_stderr_and_never_echo_values(argv: list[str] assert "SENTINEL" not in result.stdout + result.stderr +@pytest.mark.parametrize("category", sorted(RELOGIN_CATEGORIES)) +def test_format_user_error_names_the_automatic_sign_in_failure(category: str) -> None: + error = _session_expired(f"automatic sign-in failed: {category}") + + assert format_user_error(error) == ( + f"Session expired; automatic sign-in failed ({category}). Run: lighthouse auth login" + ) + + +@pytest.mark.parametrize( + "raw", + [ + "Session expired (automatic sign-in failed: not_a_category).", + "Session expired (automatic sign-in failed: CATEGORY_SENTINEL).", + "Session expired (automatic sign-in failed: code_rejected", + ], +) +def test_format_user_error_ignores_an_unknown_sign_in_category(raw: str) -> None: + assert format_user_error(raw) == "Session expired. Run: lighthouse auth login" + + +def test_display_mirrors_the_sign_in_failure_categories() -> None: + assert display._RENEWAL_FAILURE_CATEGORIES == RELOGIN_CATEGORIES + + def test_format_user_error_keeps_status_and_category_without_transport_details() -> None: response = requests.Response() response.status_code = 500 @@ -860,6 +1018,42 @@ def test_leaf_constructor_failure_is_one_json_document( assert "Error: Command failed." in result.stderr +def _d2l_response(status: int) -> requests.Response: + response = requests.Response() + response.status_code = status + response._content = b"{}" + return response + + +@pytest.mark.parametrize( + ("whoami_status", "error"), + [ + pytest.param(403, "Session expired. Run: lighthouse auth login", id="signed-out"), + pytest.param(200, "Permission denied (HTTP 403).", id="signed-in"), + ], +) +def test_courses_reports_a_403_from_an_expired_session_as_expired( + whoami_status: int, error: str, +) -> None: + # D2L answers an expired session's enrollment read with 403; only a + # whoami that is signed out too makes it a session expiry. + save_cookies(dict.fromkeys(COOKIE_NAMES, "expired-cookie-sentinel")) + + def request(_session: object, _method: str, url: str, **_kwargs: object) -> requests.Response: + return _d2l_response(whoami_status if url.endswith("/users/whoami") else 403) + + with patch.object(requests.Session, "request", request), \ + patch("lighthouse_cli.api.refresh_auth_from_browser", side_effect=RuntimeError("no browser")): + result = CliRunner().invoke(cli, ["courses", "--json"]) + human = CliRunner().invoke(cli, ["courses"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"courses": [], "error": error} + assert human.exit_code == 1 + assert f"Error: {error}" in human.stderr + assert "expired-cookie-sentinel" not in result.stdout + result.stderr + human.stdout + human.stderr + + @pytest.mark.parametrize("command", ["download", "sync"]) def test_omitted_course_without_trustworthy_config_fails_closed_as_json( command: str, tmp_path: Path, diff --git a/tests/test_credential_store.py b/tests/test_credential_store.py index 3686173..2164e97 100644 --- a/tests/test_credential_store.py +++ b/tests/test_credential_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json import sys from pathlib import Path @@ -14,6 +15,7 @@ from lighthouse_cli.auth import CredentialStore, CredentialStoreError from lighthouse_cli.config import ensure_config_dir from lighthouse_cli.credential_store import _derive_passphrase_key +from lighthouse_cli.totp import TotpParams from lighthouse_cli.utils import _loads_strict_json @@ -407,3 +409,163 @@ def test_invalid_recorded_kdf_iterations_rejected_before_derivation( CredentialStore().load() if is_int: assert str(bad_iterations) in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# Stored authenticator (authenticator.json) +# --------------------------------------------------------------------------- + +_TOTP_KEY = b"authenticator-sentinel-key" +_TOTP_BASE32 = base64.b32encode(_TOTP_KEY).decode("ascii").rstrip("=") +_TOTP_USER = "totp-owner@learner.manipal.edu" +_TOTP_METADATA = {"kind": "totp", "algorithm": "SHA1", "digits": 6, "period": 30} + + +def _totp_params(key: bytes = _TOTP_KEY) -> TotpParams: + return TotpParams(key=key) + + +def _write_totp_document( + config_dir: Path, *, metadata: dict[str, Any], secret: dict[str, Any] +) -> None: + store = CredentialStore() + store.write_artifact(store.authenticator_file, metadata=metadata, secret=secret) + + +@pytest.mark.parametrize("source", ["passphrase", "keyring"]) +def test_totp_round_trip_under_each_key_source( + config_dir: Path, request: pytest.FixtureRequest, source: str +) -> None: + if source == "keyring": + request.getfixturevalue("fake_keyring") + store = CredentialStore() + store.save_totp(_TOTP_USER, _totp_params(), last_step=None) + + path = config_dir / "authenticator.json" + doc = json.loads(path.read_text(encoding="utf-8")) + assert doc["key_source"] == source + assert {k: doc[k] for k in _TOTP_METADATA} == _TOTP_METADATA + assert path.stat().st_mode & 0o777 == 0o600 + + loaded = CredentialStore().load_totp() + assert loaded is not None + assert loaded.username == _TOTP_USER + assert loaded.params.key == _TOTP_KEY + assert loaded.last_step is None + + +def test_totp_file_holds_no_plaintext_secret_or_username(config_dir: Path) -> None: + CredentialStore().save_totp(_TOTP_USER, _totp_params(), last_step=7) + raw = (config_dir / "authenticator.json").read_text(encoding="utf-8") + assert _TOTP_BASE32 not in raw + assert _TOTP_BASE32.lower() not in raw + assert _TOTP_USER not in raw + assert "totp-owner" not in raw + + +def test_stored_totp_repr_hides_secret_fields(config_dir: Path) -> None: + CredentialStore().save_totp(_TOTP_USER, _totp_params(), last_step=3) + loaded = CredentialStore().load_totp() + rendered = repr(loaded) + assert _TOTP_USER not in rendered + assert _TOTP_BASE32 not in rendered + assert repr(_TOTP_KEY) not in rendered + assert rendered == "StoredTotp(last_step=3)" + + +def test_load_totp_absent_returns_none(config_dir: Path) -> None: + assert CredentialStore().load_totp() is None + + +_GOOD_SECRET = {"username": _TOTP_USER, "secret": _TOTP_BASE32, "last_step": 5} + + +@pytest.mark.parametrize( + ("metadata", "secret"), + [ + ({**_TOTP_METADATA, "kind": "hotp"}, _GOOD_SECRET), + ({**_TOTP_METADATA, "digits": 8}, _GOOD_SECRET), + ({**_TOTP_METADATA, "digits": 6.0}, _GOOD_SECRET), + ({**_TOTP_METADATA, "period": "30"}, _GOOD_SECRET), + ({**_TOTP_METADATA, "algorithm": "SHA256"}, _GOOD_SECRET), + ({**_TOTP_METADATA, "extra": "x"}, _GOOD_SECRET), + ({k: v for k, v in _TOTP_METADATA.items() if k != "period"}, _GOOD_SECRET), + (_TOTP_METADATA, {**_GOOD_SECRET, "last_step": True}), + (_TOTP_METADATA, {**_GOOD_SECRET, "last_step": -1}), + (_TOTP_METADATA, {**_GOOD_SECRET, "last_step": 5.0}), + (_TOTP_METADATA, {**_GOOD_SECRET, "last_step": "5"}), + (_TOTP_METADATA, {**_GOOD_SECRET, "username": ""}), + (_TOTP_METADATA, {**_GOOD_SECRET, "username": 42}), + (_TOTP_METADATA, {**_GOOD_SECRET, "secret": "not base32!"}), # pragma: allowlist secret + (_TOTP_METADATA, {**_GOOD_SECRET, "secret": 12345}), + (_TOTP_METADATA, {**_GOOD_SECRET, "extra": 1}), + (_TOTP_METADATA, {"username": _TOTP_USER, "secret": _TOTP_BASE32}), + ], +) +def test_load_totp_rejects_tampered_documents( + config_dir: Path, metadata: dict[str, Any], secret: dict[str, Any] +) -> None: + _write_totp_document(config_dir, metadata=metadata, secret=secret) + with pytest.raises(CredentialStoreError) as exc_info: + CredentialStore().load_totp() + message = str(exc_info.value) + assert message == ( + "Stored authenticator data is invalid. Run: lighthouse auth totp-setup" + ) + assert _TOTP_USER not in message + + +def test_reserve_totp_step_strictly_advances(config_dir: Path) -> None: + store = CredentialStore() + store.save_totp(_TOTP_USER, _totp_params(), last_step=None) + store.reserve_totp_step(100) + loaded = store.load_totp() + assert loaded is not None and loaded.last_step == 100 + assert loaded.username == _TOTP_USER and loaded.params.key == _TOTP_KEY + for stale in (100, 99): + with pytest.raises(CredentialStoreError, match="already used"): + store.reserve_totp_step(stale) + store.reserve_totp_step(101) + assert store.load_totp().last_step == 101 # type: ignore[union-attr] + + +def test_reserve_totp_step_without_authenticator_fails(config_dir: Path) -> None: + with pytest.raises(CredentialStoreError, match="invalid"): + CredentialStore().reserve_totp_step(1) + assert not (config_dir / "authenticator.json").exists() + + +def test_save_totp_rejects_bad_arguments(config_dir: Path) -> None: + store = CredentialStore() + with pytest.raises(CredentialStoreError): + store.save_totp("", _totp_params(), last_step=None) + with pytest.raises(CredentialStoreError): + store.save_totp(_TOTP_USER, _totp_params(), last_step=True) # type: ignore[arg-type] + with pytest.raises(CredentialStoreError): + store.save_totp(_TOTP_USER, TotpParams(key=_TOTP_KEY, digits=8), last_step=None) + assert not (config_dir / "authenticator.json").exists() + + +def test_delete_totp_is_idempotent(config_dir: Path) -> None: + store = CredentialStore() + store.save_totp(_TOTP_USER, _totp_params(), last_step=None) + assert store.delete_totp() is True + assert not (config_dir / "authenticator.json").exists() + assert store.delete_totp() is False + + +def test_totp_paths_reject_symlinks(config_dir: Path, tmp_path: Path) -> None: + outside = tmp_path / "outside.json" + outside.write_text("outside-sentinel", encoding="utf-8") + (config_dir / "authenticator.json").symlink_to(outside) + store = CredentialStore() + for action in ( + lambda: store.save_totp(_TOTP_USER, _totp_params(), last_step=None), + store.load_totp, + lambda: store.reserve_totp_step(1), + store.delete_totp, + ): + with pytest.raises(CredentialStoreError, match="symlink"): + action() + assert (config_dir / "authenticator.json").is_symlink() + assert outside.read_text(encoding="utf-8") == "outside-sentinel" diff --git a/tests/test_ms_auth_provider.py b/tests/test_ms_auth_provider.py new file mode 100644 index 0000000..8eccba4 --- /dev/null +++ b/tests/test_ms_auth_provider.py @@ -0,0 +1,528 @@ +"""Stored-authenticator (``totp_provider``) mode, server errors and failure +categories in ``MicrosoftSSOClient``. + +Reuses the scripted transport from the characterization suite. Codes and +tokens are sentinels; none is printed. +""" + +from __future__ import annotations + +import sys +import traceback +from collections.abc import Callable, Iterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +import pytest + +from lighthouse_cli import session_renewal as sr +from lighthouse_cli.config import COOKIE_NAMES, save_mfa_pending +from lighthouse_cli.ms_auth import MicrosoftSSOClient, MicrosoftSSOError, build_sso_error +from tests.test_ms_auth_characterization import ( + BEGIN_URL, + CREDS_POST_URL, + END_URL, + LEGACY_MFA_HTML, + LOGIN_INIT_URL, + MFA_PAGE_URL, + MS_BASE, + PASSWORD, + PROCESS_URL, + USERNAME, + FakeResponse, + ScriptedSession, + begin_success, + config_html, + end_auth_payloads, + end_success, + home, + install_fake_playwright, + mfa_start, + saml_at, + set_d2l_cookies, + sso_start, +) + +PROVIDER_CODE = "135790" +_CHECKPOINT_FUNCS = ("load_mfa_pending", "save_mfa_pending", "update_mfa_pending", "clear_mfa_pending") + + +@pytest.fixture +def session() -> ScriptedSession: + return ScriptedSession() + + +@pytest.fixture +def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + d = tmp_path / "config" + monkeypatch.setenv("LIGHTHOUSE_CONFIG_DIR", str(d)) + return d + + +@pytest.fixture +def no_checkpoint(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail the test if provider mode touches the pending MFA checkpoint.""" + def forbidden(*_a: Any, **_k: Any) -> None: + raise AssertionError("provider mode touched mfa_pending") + + for name in _CHECKPOINT_FUNCS: + monkeypatch.setattr(f"lighthouse_cli.config.{name}", forbidden) + + +class Provider: + """Records each call and the transport calls made before it.""" + + def __init__(self, session: ScriptedSession, code: str = PROVIDER_CODE) -> None: + self._session = session + self._code = code + self.calls_seen: list[list[tuple[str, str]]] = [] + + def __call__(self) -> str: + self.calls_seen.append(list(self._session.calls)) + return self._code + + +def run(session: ScriptedSession, **kwargs: Any) -> dict[str, str]: + client = MicrosoftSSOClient() + kwargs.setdefault("mfa_method", "app") + with patch("requests.Session", return_value=session): + try: + return client.login(USERNAME, PASSWORD, kwargs.pop("totp_code", None), **kwargs) + finally: + client.close() + + +def category_of(exc: pytest.ExceptionInfo[MicrosoftSSOError]) -> str | None: + return exc.value.category + + +# --------------------------------------------------------------------------- +# Provider mode +# --------------------------------------------------------------------------- + + +class TestProviderMode: + def test_success_calls_provider_once_after_beginauth( + self, session: ScriptedSession, config_dir: Path, no_checkpoint: None + ) -> None: + provider = Provider(session) + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success(), end_success(), saml_at(PROCESS_URL), home()) + set_d2l_cookies(session) + assert set(run(session, totp_provider=provider)) == set(COOKIE_NAMES) + assert len(provider.calls_seen) == 1 + assert provider.calls_seen[0][-1] == ("POST", BEGIN_URL) + payloads = end_auth_payloads(session) + assert len(payloads) == 1 + assert payloads[0]["AuthMethodId"] == "PhoneAppOTP" + assert payloads[0]["AdditionalAuthData"] == PROVIDER_CODE + + def test_existing_human_checkpoint_survives( + self, session: ScriptedSession, config_dir: Path + ) -> None: + save_mfa_pending({"mfa_method": "sms", "mfa_page_url": MFA_PAGE_URL, "marker": "human"}) + checkpoint = config_dir / "mfa_pending.json" + before = checkpoint.read_bytes() + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success(), end_success(), saml_at(PROCESS_URL), home()) + set_d2l_cookies(session) + run(session, totp_provider=Provider(session)) + assert checkpoint.read_bytes() == before + + @pytest.mark.parametrize( + "end_response", + [ + pytest.param({"Retry": True}, id="retry"), + pytest.param({"Success": False, "ResultValue": "OathCodeIncorrect"}, id="incorrect"), + pytest.param( + {"Success": False, "ResultValue": "AuthenticationPreviouslyCompleted"}, + id="previously-completed", + ), + pytest.param({"Success": False, "ErrCode": 500121}, id="errcode"), + ], + ) + def test_any_endauth_failure_is_single_fixed_code_rejected( + self, + session: ScriptedSession, + config_dir: Path, + no_checkpoint: None, + end_response: dict[str, Any], + ) -> None: + provider = Provider(session) + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success(), FakeResponse(json_data=end_response)) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, totp_provider=provider) + assert caught.value.args[0] == "The authenticator code was rejected." + assert category_of(caught) == "code_rejected" + assert len(end_auth_payloads(session)) == 1 + assert len(provider.calls_seen) == 1 + assert ("POST", PROCESS_URL) not in session.calls + + @pytest.mark.parametrize( + ("field", "upstream_value"), + [ + pytest.param("ErrCode", f"OTP-{PROVIDER_CODE}", id="errcode"), + pytest.param("Message", f"Code {PROVIDER_CODE} was rejected", id="message"), + pytest.param("ResultValue", f"Invalid code {PROVIDER_CODE}", id="result-value"), + ], + ) + def test_provider_rejection_never_echoes_endauth_details( + self, + session: ScriptedSession, + config_dir: Path, + no_checkpoint: None, + field: str, + upstream_value: str, + ) -> None: + session.enqueue( + *mfa_start("PhoneAppOTP"), begin_success(), + FakeResponse(json_data={"Success": False, field: upstream_value}), + ) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, totp_provider=Provider(session)) + + rendered = str(caught.value) + formatted = "".join(traceback.format_exception(caught.type, caught.value, caught.tb)) + assert caught.value.category == "code_rejected" + assert PROVIDER_CODE not in rendered + assert PROVIDER_CODE not in formatted + + @pytest.mark.parametrize( + ("kwargs", "mfa_method"), + [ + pytest.param({}, "auto", id="auto"), + pytest.param({}, "sms", id="sms"), + pytest.param({"totp_code": "123456"}, "app", id="literal-code"), + pytest.param({"read_totp_after_challenge": True}, "app", id="stdin-code"), + pytest.param({"defer_mfa_to_pending": True}, "app", id="deferred"), + ], + ) + def test_guards_reject_before_any_io( + self, + session: ScriptedSession, + config_dir: Path, + no_checkpoint: None, + kwargs: dict[str, Any], + mfa_method: str, + ) -> None: + provider = Provider(session) + with pytest.raises(MicrosoftSSOError, match="stored authenticator"): + run(session, mfa_method=mfa_method, totp_provider=provider, **kwargs) + assert session.calls == [] + assert provider.calls_seen == [] + + @pytest.mark.parametrize("auth_method_id", ["OneWaySMS", "PhoneAppNotification", "TwoWayVoiceMobile"]) + def test_non_otp_account_is_method_unavailable_before_beginauth( + self, session: ScriptedSession, config_dir: Path, no_checkpoint: None, auth_method_id: str + ) -> None: + provider = Provider(session) + session.enqueue(*mfa_start(auth_method_id)) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, totp_provider=provider) + assert category_of(caught) == "method_unavailable" + assert ("POST", BEGIN_URL) not in session.calls + assert provider.calls_seen == [] + + def test_legacy_form_fails_closed( + self, session: ScriptedSession, config_dir: Path, no_checkpoint: None + ) -> None: + provider = Provider(session) + session.enqueue(*sso_start(), FakeResponse(200, html=LEGACY_MFA_HTML, url=MFA_PAGE_URL)) + with patch("sys.stdin") as stdin: + with pytest.raises(MicrosoftSSOError) as caught: + run(session, totp_provider=provider) + stdin.readline.assert_not_called() + assert category_of(caught) == "method_unavailable" + assert ("POST", PROCESS_URL) not in session.calls + assert provider.calls_seen == [] + + def test_provider_error_propagates_without_endauth( + self, session: ScriptedSession, config_dir: Path, no_checkpoint: None + ) -> None: + def skewed() -> str: + raise MicrosoftSSOError("No fresh authenticator time step.", category="clock_skew") + + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success()) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, totp_provider=skewed) + assert category_of(caught) == "clock_skew" + assert end_auth_payloads(session) == [] + + def test_checkpointing_is_restored_after_provider_login( + self, session: ScriptedSession, config_dir: Path + ) -> None: + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success(), FakeResponse(json_data={"Retry": True})) + client = MicrosoftSSOClient() + with patch("requests.Session", return_value=session), pytest.raises(MicrosoftSSOError): + client.login(USERNAME, PASSWORD, mfa_method="app", totp_provider=Provider(session)) + assert client._checkpoint_enabled is True + + def test_provider_mode_is_silent_on_stdout( + self, session: ScriptedSession, config_dir: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success(), end_success(), saml_at(PROCESS_URL), home()) + set_d2l_cookies(session) + with patch("sys.stdin") as stdin: + stdin.isatty.return_value = True + run(session, totp_provider=Provider(session)) + captured = capsys.readouterr() + assert captured.out == "" + assert "Second factor required" not in captured.err + assert PROVIDER_CODE not in captured.err + + +# --------------------------------------------------------------------------- +# Human-mode EndAuth detail +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("err_code", "expected"), + [(987654, "unknown error"), (50074, "50074")], +) +def test_endauth_renders_only_known_error_codes( + session: ScriptedSession, config_dir: Path, err_code: int, expected: str +) -> None: + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success(), FakeResponse(json_data={"ErrCode": err_code})) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, totp_code="123456") + assert str(caught.value).startswith(f"2FA verification failed: {expected}") + if expected == "unknown error": + assert str(err_code) not in str(caught.value) + + +# --------------------------------------------------------------------------- +# Server errors and the browser bootstrap +# --------------------------------------------------------------------------- + + +_SERVER_ERROR = "The sign-in service returned a server error (HTTP 503). Try again later." + + +def test_5xx_on_main_flow_is_network(session: ScriptedSession, config_dir: Path) -> None: + session.enqueue(FakeResponse(503, url=LOGIN_INIT_URL)) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, mfa_method="auto") + assert caught.value.args[0] == _SERVER_ERROR + assert category_of(caught) == "network" + + +def test_5xx_on_microsoft_config_fetch_is_network(session: ScriptedSession, config_dir: Path) -> None: + session.enqueue( + FakeResponse(302, url=LOGIN_INIT_URL, headers={"Location": MS_BASE + "/tenant-id/saml2?SAMLRequest=z"}), + FakeResponse(503, url=MS_BASE + "/tenant-id/saml2?SAMLRequest=z"), + ) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, mfa_method="auto") + assert caught.value.args[0] == _SERVER_ERROR + assert category_of(caught) == "network" + + +def test_5xx_on_endauth_is_network_in_provider_mode( + session: ScriptedSession, config_dir: Path, no_checkpoint: None +) -> None: + session.enqueue(*mfa_start("PhoneAppOTP"), begin_success(), FakeResponse(503, url=END_URL)) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, totp_provider=Provider(session)) + assert category_of(caught) == "network" + + +def _http_bootstrap(*, me: int = 200, probe: int = 200, dsso: int = 200, gct: int = 200) -> list[FakeResponse]: + return [ + FakeResponse(me, html="{}", url="https://login.live.com/Me.htm?v=3"), + FakeResponse(probe, html="", url=f"{MS_BASE}/ssoprobe"), + FakeResponse(dsso, json_data={}, url=f"{MS_BASE}/dssostatus"), + FakeResponse(gct, json_data={"FlowToken": "FLOW-TOKEN-2"}, url=f"{MS_BASE}/common/GetCredentialType"), + FakeResponse(probe, html="", url=f"{MS_BASE}/ssoprobe"), + FakeResponse(dsso, json_data={}, url=f"{MS_BASE}/dssostatus"), + ] + + +@pytest.fixture +def no_playwright(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "playwright", None) + monkeypatch.setitem(sys.modules, "playwright.sync_api", None) + + +def test_optional_probe_5xx_does_not_abort( + session: ScriptedSession, config_dir: Path, no_playwright: None +) -> None: + session.enqueue( + *sso_start(config=config_html(url_get_credential_type=True)), + *_http_bootstrap(me=503, probe=502, dsso=500), + saml_at(CREDS_POST_URL), + home(), + ) + set_d2l_cookies(session) + assert set(run(session, mfa_method="auto")) == set(COOKIE_NAMES) + + +def test_get_credential_type_5xx_is_network( + session: ScriptedSession, config_dir: Path, no_playwright: None +) -> None: + session.enqueue( + *sso_start(config=config_html(url_get_credential_type=True)), + *_http_bootstrap(gct=503)[:4], + ) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, mfa_method="auto") + assert caught.value.args[0] == _SERVER_ERROR + assert category_of(caught) == "network" + + +def test_200_error_page_with_50059_is_network(session: ScriptedSession, config_dir: Path) -> None: + error_page = ( + "" + ) + session.enqueue(*sso_start(error_page)) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, mfa_method="auto") + assert category_of(caught) == "network" + + +class _PwError(Exception): + pass + + +class _PwTimeoutError(_PwError): + pass + + +def _fake_sync_playwright( + goto: Callable[..., Any], *, evaluate: Callable[..., Any] | None = None +) -> Callable[[], Any]: + page = SimpleNamespace(goto=goto) + if evaluate is not None: + page.query_selector = lambda *_a, **_k: None + page.evaluate = evaluate + context = SimpleNamespace(add_cookies=lambda cookies: None, new_page=lambda: page) + browser = SimpleNamespace(new_context=lambda **kw: context, close=lambda: None) + playwright = SimpleNamespace(chromium=SimpleNamespace(launch=lambda **kw: browser)) + + class Manager: + def __enter__(self) -> Any: + return playwright + + def __exit__(self, *exc: Any) -> None: + return None + + return Manager + + +@pytest.fixture +def fake_pw_errors(monkeypatch: pytest.MonkeyPatch) -> Iterator[Callable[[Callable[..., Any]], None]]: + def install(goto: Callable[..., Any]) -> None: + install_fake_playwright(monkeypatch, _fake_sync_playwright(goto)) + api = sys.modules["playwright.sync_api"] + api.Error = _PwError # type: ignore[attr-defined] + api.TimeoutError = _PwTimeoutError # type: ignore[attr-defined] + + yield install + + +def _raiser(exc: BaseException) -> Callable[..., Any]: + def goto(*_a: Any, **_k: Any) -> Any: + raise exc + + return goto + + +@pytest.mark.parametrize( + "goto", + [ + pytest.param(_raiser(_PwTimeoutError("Timeout 60000ms exceeded.")), id="timeout"), + pytest.param(_raiser(_PwError("net::ERR_NAME_NOT_RESOLVED at https://x")), id="net-err"), + ], +) +def test_browser_navigation_outage_is_network( + session: ScriptedSession, + config_dir: Path, + fake_pw_errors: Callable[[Callable[..., Any]], None], + goto: Callable[..., Any], +) -> None: + fake_pw_errors(goto) + session.enqueue(*sso_start(config=config_html(url_get_credential_type=True))) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, mfa_method="auto") + assert category_of(caught) == "network" + assert "net::ERR_" not in str(caught.value) + assert "Timeout" not in str(caught.value) + + +def test_browser_navigation_5xx_is_network( + session: ScriptedSession, config_dir: Path, fake_pw_errors: Callable[[Callable[..., Any]], None] +) -> None: + fake_pw_errors(lambda *_a, **_k: SimpleNamespace(status=503)) + session.enqueue(*sso_start(config=config_html(url_get_credential_type=True))) + with pytest.raises(MicrosoftSSOError) as caught: + run(session, mfa_method="auto") + assert caught.value.args[0] == _SERVER_ERROR + assert category_of(caught) == "network" + + +def test_browser_missing_config_maps_to_interrupt( + session: ScriptedSession, config_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def missing_config(*_args: Any, **_kwargs: Any) -> Any: + raise _PwError("$Config is not defined") + + install_fake_playwright( + monkeypatch, + _fake_sync_playwright( + lambda *_args, **_kwargs: SimpleNamespace(status=200), + evaluate=missing_config, + ), + ) + api = sys.modules["playwright.sync_api"] + api.Error = _PwError # type: ignore[attr-defined] + api.TimeoutError = _PwTimeoutError # type: ignore[attr-defined] + session.enqueue(*sso_start(config=config_html(url_get_credential_type=True))) + + with pytest.raises(MicrosoftSSOError) as caught: + run(session, mfa_method="auto") + + assert "Playwright username step failed" in str(caught.value) + assert caught.value.category is None + assert sr._as_renewal_error(caught.value).category == "interrupt" + assert session.calls == [("GET", LOGIN_INIT_URL), ("GET", MS_BASE + "/tenant-id/saml2?SAMLRequest=z")] + + +def test_other_browser_failure_stays_uncategorized( + session: ScriptedSession, config_dir: Path, fake_pw_errors: Callable[[Callable[..., Any]], None] +) -> None: + fake_pw_errors(_raiser(_PwError("Target page crashed"))) + session.enqueue(*sso_start(config=config_html(url_get_credential_type=True))) + with pytest.raises(MicrosoftSSOError, match="Playwright username step failed") as caught: + run(session, mfa_method="auto") + assert category_of(caught) is None + + +# --------------------------------------------------------------------------- +# Categories from Microsoft error codes +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("code", "category"), + [ + (50126, "credentials_rejected"), + (50053, "credentials_rejected"), + (50055, "credentials_rejected"), + (50057, "credentials_rejected"), + (50059, "network"), + (50076, "interrupt"), + (53003, "interrupt"), + (99999, None), + (None, None), + ], +) +def test_build_sso_error_category(code: int | None, category: str | None) -> None: + assert build_sso_error(code, "msg", "POST credentials").category == category + + +def test_unknown_category_is_dropped() -> None: + assert MicrosoftSSOError("x", category="not-a-category").category is None + assert "category" not in str(MicrosoftSSOError("x", category="network")) diff --git a/tests/test_read_only_auth.py b/tests/test_read_only_auth.py index 5ea9080..bcbd2d0 100644 --- a/tests/test_read_only_auth.py +++ b/tests/test_read_only_auth.py @@ -99,6 +99,28 @@ def test_read_only_client_does_not_refresh_or_save_after_401(method: str) -> Non save.assert_not_called() +@pytest.mark.parametrize("stored", [_cookies(), {}], ids=["expired", "missing"]) +def test_read_only_client_never_signs_in_with_the_stored_authenticator( + stored: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + renew = MagicMock() + monkeypatch.setattr(api, "unattended_configured", lambda: True) + monkeypatch.setattr(api, "renew_session", renew) + client, session = _authenticated_client() + client._loaded = True + client._cookies = dict(stored) + session.request.return_value = MagicMock(status_code=401, headers={}) + + with patch.object(api, "refresh_auth_from_browser") as refresh: + with pytest.raises(SessionExpiredError): + client._request("GET", f"{BASE_URL}/d2l/api/versions/") + + renew.assert_not_called() + refresh.assert_not_called() + assert session.request.call_count == (1 if stored else 0) + + def test_normal_client_still_migrates_legacy_cookies( cookie_path: Path, ) -> None: diff --git a/tests/test_secret_absence.py b/tests/test_secret_absence.py index 202e10f..a9d0c4f 100644 --- a/tests/test_secret_absence.py +++ b/tests/test_secret_absence.py @@ -7,6 +7,7 @@ from __future__ import annotations +import base64 import traceback import urllib.parse from pathlib import Path @@ -16,10 +17,13 @@ import pytest import requests +from lighthouse_cli import session_renewal from lighthouse_cli.auth import CredentialStore, LoginPlan from lighthouse_cli.cli import cli from lighthouse_cli.config import COOKIE_NAMES, save_cookies from lighthouse_cli.ms_auth import MfaPendingError, MicrosoftSSOClient, MicrosoftSSOError +from lighthouse_cli.session_renewal import RenewalError, renew_session +from lighthouse_cli.totp import parse_totp_secret, totp_code # --------------------------------------------------------------------------- # Sentinels — unique strings planted in every secret-bearing position. @@ -328,3 +332,147 @@ def test_traceback_path_carries_no_secret_values( for sentinel in ALL_SENTINELS: assert sentinel not in tb assert tb != "NoneType: None\n" + + +# --------------------------------------------------------------------------- +# Unattended sign-in with the stored authenticator +# --------------------------------------------------------------------------- + +S_USERNAME = "sentinel-user-k2v7@manipal.edu" +S_KEY = "SENTINELKEYQ7X4KSENTINELKEYQ7X4K" +S_KEY_URI = f"otpauth://totp/MAHE:{S_USERNAME}?secret={S_KEY}&issuer=MAHE" +T_SETUP = 1_800_000_015.0 +# The next time step, so the provider never waits for a fresh one. +T_SIGN_IN = T_SETUP + 30 + + +def _generated_code(at: float) -> str: + params = parse_totp_secret(S_KEY) + return totp_code(params.key, for_time=at, digits=params.digits, period=params.period) + + +def _unattended_sentinels(code: str) -> list[str]: + return [*ALL_SENTINELS, S_USERNAME, S_KEY, S_KEY_URI, code] + + +def _assert_absent(text: str, sentinels: list[str]) -> None: + for sentinel in sentinels: + assert sentinel not in text + + +def _assert_nothing_on_disk(config_dir: Path, sentinels: list[str]) -> None: + """No sentinel, nor the decoded authenticator key, in any stored file.""" + decoded_key = base64.b32decode(S_KEY) + blobs = [path.read_bytes() for path in sorted(config_dir.rglob("*")) if path.is_file()] + assert blobs, "expected at least one artifact to scan" + for blob in blobs: + assert decoded_key not in blob + for sentinel in sentinels: + assert sentinel.encode() not in blob + assert urllib.parse.quote(sentinel).encode() not in blob + + +class RecordingSession(ScriptedSession): + """Also records each POST's arguments, to show the code really was sent.""" + + def __init__(self) -> None: + super().__init__() + self.sent: list[str] = [] + + def post(self, url: str, **kwargs: Any) -> FakeResponse: + self.sent.append(repr(kwargs)) + return super().post(url, **kwargs) + + +def _rejected_end_auth(code: str) -> FakeResponse: + # A hostile EndAuth reply that echoes the submitted code and the username. + return FakeResponse(json_data={ + "Success": False, "Retry": True, "ErrCode": code, "ResultValue": code, + "Message": f"Code {code} for {S_USERNAME} was rejected", + }) + + +@pytest.fixture +def stored_authenticator( + sealed_dir: Path, cli_runner: Any, monkeypatch: pytest.MonkeyPatch +) -> Path: + """Saved sentinel credentials plus an authenticator set up from a URI.""" + monkeypatch.delenv("LIGHTHOUSE_MFA_METHOD", raising=False) + monkeypatch.setattr(session_renewal, "_clock", lambda: T_SETUP) + CredentialStore().save(S_USERNAME, S_PASSWORD) + + result = cli_runner.invoke(cli, ["auth", "totp-setup", "--json"], input=S_KEY_URI + "\n") + + assert result.exit_code == 0 + _assert_absent(result.stdout + result.stderr, _unattended_sentinels(_generated_code(T_SETUP))) + monkeypatch.setattr(session_renewal, "_clock", lambda: T_SIGN_IN) + return sealed_dir + + +class TestUnattendedSignInLeavesNoSecrets: + def test_signing_in_leaves_no_plaintext_secret( + self, stored_authenticator: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + code = _generated_code(T_SIGN_IN) + session = RecordingSession() + session.enqueue(*_pre_mfa_responses(), *_post_mfa_responses()) + _plant_d2l_cookies(session) + + with patch("requests.Session", return_value=session): + result = renew_session(observed_cookies={}, validate=lambda _cookies: "valid", automatic=True) + + assert result.signed_in is True + # The code really was submitted, so its absence everywhere else counts. + assert any(code in body for body in session.sent) + _assert_nothing_on_disk(stored_authenticator, _unattended_sentinels(code)) + captured = capsys.readouterr() + _assert_absent(captured.out + captured.err + repr(result), _unattended_sentinels(code)) + + def test_a_rejected_code_leaves_no_secret_in_the_error_or_breaker_state( + self, stored_authenticator: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + code = _generated_code(T_SIGN_IN) + session = RecordingSession() + session.enqueue(*_pre_mfa_responses(), _rejected_end_auth(code)) + + with patch("requests.Session", return_value=session): + with pytest.raises(RenewalError) as exc_info: + renew_session(observed_cookies={}, validate=lambda _cookies: "valid", automatic=True) + + assert exc_info.value.category == "code_rejected" + assert any(code in body for body in session.sent) + assert (stored_authenticator / "relogin_state.json").is_file() + _assert_nothing_on_disk(stored_authenticator, _unattended_sentinels(code)) + rendered = "".join(traceback.format_exception(exc_info.value)) + captured = capsys.readouterr() + _assert_absent( + str(exc_info.value) + repr(exc_info.value) + rendered + captured.out + captured.err, + _unattended_sentinels(code), + ) + + @pytest.mark.parametrize("outcome", ["signed-in", "code-rejected"]) + def test_cli_login_with_the_stored_authenticator_prints_no_secret( + self, + outcome: str, + stored_authenticator: Path, + cli_runner: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("LIGHTHOUSE_USERNAME", S_USERNAME) + monkeypatch.setenv("LIGHTHOUSE_PASSWORD", S_PASSWORD) + code = _generated_code(T_SIGN_IN) + session = RecordingSession() + if outcome == "signed-in": + session.enqueue(*_pre_mfa_responses(), *_post_mfa_responses()) + _plant_d2l_cookies(session) + else: + session.enqueue(*_pre_mfa_responses(), _rejected_end_auth(code)) + + with patch("requests.Session", return_value=session), \ + patch("lighthouse_cli.auth.probe_cookies", return_value="valid"): + result = cli_runner.invoke(cli, ["auth", "login", "--json"], catch_exceptions=False) + + assert result.exit_code == (0 if outcome == "signed-in" else 1) + assert any(code in body for body in session.sent) + _assert_absent(result.stdout + result.stderr, _unattended_sentinels(code)) + _assert_nothing_on_disk(stored_authenticator, _unattended_sentinels(code)) diff --git a/tests/test_secrets_store.py b/tests/test_secrets_store.py index 7fd7ef5..7fa83a6 100644 --- a/tests/test_secrets_store.py +++ b/tests/test_secrets_store.py @@ -156,9 +156,8 @@ def test_no_side_effects_when_preflight_fails( mock_sso = MagicMock() with ( patch("lighthouse_cli.auth.MicrosoftSSOClient", return_value=mock_sso) as sso_cls, - patch("lighthouse_cli.auth.LighthouseClient") as client_cls, + patch("lighthouse_cli.auth.probe_cookies", return_value="valid"), ): - client_cls.return_value.check_auth.return_value = True result = cli_runner.invoke(cli, ["auth", "login", "--json"], catch_exceptions=False) assert result.exit_code == 1 diff --git a/tests/test_session_renewal.py b/tests/test_session_renewal.py new file mode 100644 index 0000000..cc60ae4 --- /dev/null +++ b/tests/test_session_renewal.py @@ -0,0 +1,690 @@ +"""Unattended sign-in: lock, circuit breaker, reuse, gates and step reservation.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +import time +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Any + +import pytest +import requests + +from lighthouse_cli import session_renewal as sr +from lighthouse_cli.config import load_cookies, save_cookies +from lighthouse_cli.credential_store import CredentialStore, CredentialStoreError +from lighthouse_cli.ms_errors import MicrosoftSSOError +from lighthouse_cli.session_renewal import ( + RenewalError, + RenewalResult, + RenewalUnavailableError, + renew_session, + renewal_lock, + unattended_configured, + unattended_status, +) +from lighthouse_cli.totp import parse_totp_secret, totp_code + +USERNAME = "student@example.edu" +PASSWORD = "pw-sentinel-Q7" # pragma: allowlist secret +SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" # pragma: allowlist secret +T0 = 1_800_000_015.0 # 15 s into a 30 s step +STEP0 = int(T0 // 30) + + +def _cookies(tag: str) -> dict[str, str]: + return { + "d2lSameSiteCanaryA": f"canA-{tag}", + "d2lSameSiteCanaryB": f"canB-{tag}", + "d2lSecureSessionVal": f"secure-{tag}", + "d2lSessionVal": f"session-{tag}", + } + + +class Clock: + def __init__(self, now: float = T0) -> None: + self.now = now + self.sleeps: list[float] = [] + + def time(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +class FakeSSO: + """Stands in for ``MicrosoftSSOClient``; records each login.""" + + def __init__(self) -> None: + self.logins: list[dict[str, Any]] = [] + self.codes: list[str] = [] + self.closed = 0 + self.result: Callable[[], dict[str, str]] = lambda: _cookies("fresh") + self.delay = 0.0 + + def factory(self) -> FakeSSO: + return self + + def login(self, username: str, password: str, **kwargs: Any) -> dict[str, str]: + self.logins.append({"username": username, "password": password, **kwargs}) + self.codes.append(kwargs["totp_provider"]()) + if self.delay: + time.sleep(self.delay) + return self.result() + + def close(self) -> None: + self.closed += 1 + + +class Validator: + def __init__(self, *answers: sr.SessionProbe) -> None: + self.answers = list(answers) or ["valid"] + self.calls: list[dict[str, str]] = [] + + def __call__(self, cookies: dict[str, str]) -> sr.SessionProbe: + self.calls.append(cookies) + return self.answers.pop(0) if len(self.answers) > 1 else self.answers[0] + + +@pytest.fixture +def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + d = tmp_path / "cfg" + d.mkdir(mode=0o700) + monkeypatch.setenv("LIGHTHOUSE_CONFIG_DIR", str(d)) + return d + + +@pytest.fixture +def clock(monkeypatch: pytest.MonkeyPatch) -> Clock: + c = Clock() + monkeypatch.setattr(sr, "_clock", c.time) + monkeypatch.setattr(sr, "_sleep", c.sleep) + return c + + +@pytest.fixture +def sso(monkeypatch: pytest.MonkeyPatch) -> FakeSSO: + fake = FakeSSO() + monkeypatch.setattr(sr, "MicrosoftSSOClient", fake.factory) + return fake + + +@pytest.fixture +def configured(config_dir: Path, clock: Clock, sso: FakeSSO) -> CredentialStore: + store = CredentialStore() + store.save(USERNAME, PASSWORD) + store.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=None) + return store + + +def _renew(validate: Callable[[dict[str, str]], sr.SessionProbe] | None = None, **kwargs: Any) -> RenewalResult: + kwargs.setdefault("observed_cookies", {}) + kwargs.setdefault("automatic", True) + return renew_session(validate=validate or Validator("valid"), **kwargs) + + +def _state(config_dir: Path) -> dict[str, Any] | None: + path = config_dir / "relogin_state.json" + if not path.exists(): + return None + data: dict[str, Any] = json.loads(path.read_text()) + return data + + +def _fail_with(sso: FakeSSO, exc: BaseException) -> None: + def raise_it() -> dict[str, str]: + raise exc + + sso.result = raise_it + + +# --------------------------------------------------------------------------- +# Success, availability and reuse +# --------------------------------------------------------------------------- + + +def test_success_signs_in_saves_cookies_and_reserves_the_step( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + validate = Validator("valid") + result = _renew(validate) + assert result.signed_in is True + assert result.cookies == _cookies("fresh") + assert load_cookies(read_only=True) == _cookies("fresh") + assert validate.calls == [_cookies("fresh")] + [call] = sso.logins + assert (call["username"], call["password"], call["mfa_method"]) == (USERNAME, PASSWORD, "app") + key = parse_totp_secret(SECRET).key + assert sso.codes == [totp_code(key, for_time=T0)] + stored = configured.load_totp() + assert stored is not None and stored.last_step == STEP0 + assert sso.closed == 1 + assert _state(config_dir) is None + assert (config_dir / "relogin.lock").stat().st_mode & 0o777 == 0o600 + + +def test_success_clears_a_network_cooldown(configured: CredentialStore, config_dir: Path, clock: Clock) -> None: + (config_dir / "relogin_state.json").write_text(json.dumps({ + "v": 1, "blocked": False, "category": "network", + "since": "2027-01-01T00:00:00+00:00", "last_attempt": "2027-01-01T00:00:00+00:00", + })) + assert _renew().signed_in is True + assert _state(config_dir) is None + + +def test_result_repr_hides_cookies(configured: CredentialStore) -> None: + result = _renew() + assert "secure-fresh" not in repr(result) + assert repr(result) == "RenewalResult(signed_in=True)" + + +@pytest.mark.parametrize(("has_credentials", "has_totp"), [(False, True), (True, False), (False, False)]) +def test_unavailable_without_stored_pair( + config_dir: Path, clock: Clock, sso: FakeSSO, has_credentials: bool, has_totp: bool +) -> None: + store = CredentialStore() + if has_credentials: + store.save(USERNAME, PASSWORD) + if has_totp: + store.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=None) + with pytest.raises(RenewalUnavailableError): + _renew() + assert sso.logins == [] + assert _state(config_dir) is None + + +def test_unavailable_when_usernames_differ(configured: CredentialStore, config_dir: Path, sso: FakeSSO) -> None: + with pytest.raises(RenewalUnavailableError): + _renew(automatic=False, credentials=("other@example.edu", PASSWORD)) + assert sso.logins == [] + assert _state(config_dir) is None + + +def test_username_match_is_case_insensitive(configured: CredentialStore, sso: FakeSSO) -> None: + _renew(automatic=False, credentials=(USERNAME.upper(), PASSWORD)) + assert sso.logins[0]["username"] == USERNAME.upper() + + +def test_reuses_newer_valid_cookies_without_signing_in(configured: CredentialStore, sso: FakeSSO) -> None: + save_cookies(_cookies("other-caller")) + validate = Validator("valid") + result = _renew(validate, observed_cookies=_cookies("stale")) + assert result.signed_in is False + assert result.cookies == _cookies("other-caller") + assert sso.logins == [] + + +def test_same_cookies_as_observed_are_not_reused(configured: CredentialStore, sso: FakeSSO) -> None: + save_cookies(_cookies("stale")) + validate = Validator("valid") + result = _renew(validate, observed_cookies=_cookies("stale")) + assert result.signed_in is True + assert validate.calls == [_cookies("fresh")] + + +def test_invalid_newer_cookies_fall_through_to_sign_in(configured: CredentialStore, sso: FakeSSO) -> None: + save_cookies(_cookies("other-caller")) + result = _renew(Validator("invalid", "valid"), observed_cookies=_cookies("stale")) + assert result.signed_in is True + assert len(sso.logins) == 1 + + +def test_unavailable_probe_on_reuse_records_network_cooldown( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + save_cookies(_cookies("other-caller")) + with pytest.raises(RenewalError) as caught: + _renew(Validator("unavailable"), observed_cookies=_cookies("stale")) + assert caught.value.category == "network" + assert sso.logins == [] + state = _state(config_dir) + assert state is not None and (state["blocked"], state["category"]) == (False, "network") + + +def test_explicit_login_skips_reuse(configured: CredentialStore, sso: FakeSSO) -> None: + save_cookies(_cookies("other-caller")) + result = _renew(automatic=False, observed_cookies=_cookies("stale")) + assert result.signed_in is True + assert len(sso.logins) == 1 + + +def test_explicit_login_uses_supplied_credentials_and_persists_them( + configured: CredentialStore, sso: FakeSSO +) -> None: + _renew(automatic=False, credentials=(USERNAME, "new-pw-sentinel"), persist_credentials=True) + assert sso.logins[0]["password"] == "new-pw-sentinel" # pragma: allowlist secret + assert configured.load() == (USERNAME, "new-pw-sentinel") + + +def test_credentials_not_persisted_when_validation_fails(configured: CredentialStore) -> None: + with pytest.raises(RenewalError): + _renew(Validator("invalid"), automatic=False, credentials=(USERNAME, "new-pw-sentinel"), + persist_credentials=True) + assert configured.load() == (USERNAME, PASSWORD) + + +# --------------------------------------------------------------------------- +# Failure categories and the circuit breaker +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("exc", "category", "blocked"), + [ + (MicrosoftSSOError("x", category="credentials_rejected"), "credentials_rejected", True), + (MicrosoftSSOError("x", category="code_rejected"), "code_rejected", True), + (MicrosoftSSOError("x", category="method_unavailable"), "method_unavailable", True), + (MicrosoftSSOError("x", category="clock_skew"), "clock_skew", True), + (MicrosoftSSOError("x", category="network"), "network", False), + (MicrosoftSSOError("needs a person"), "interrupt", True), + (requests.ConnectionError("down"), "network", False), + (RuntimeError("boom"), "unknown", True), + ], +) +def test_failures_are_categorized_and_recorded( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, + exc: BaseException, category: str, blocked: bool, +) -> None: + _fail_with(sso, exc) + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == category + assert caught.value.__cause__ is None and caught.value.__suppress_context__ + assert str(caught.value) == sr._MESSAGES[category] + state = _state(config_dir) + assert state is not None + assert (state["blocked"], state["category"]) == (blocked, category) + assert state["since"] == state["last_attempt"] == "2027-01-15T08:00:15+00:00" + assert sso.closed == 1 + + +def test_store_failures_are_not_recorded(configured: CredentialStore, config_dir: Path, sso: FakeSSO) -> None: + _fail_with(sso, CredentialStoreError("disk")) + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "store" + assert _state(config_dir) is None + + +@pytest.mark.parametrize(("probe", "category"), [("invalid", "interrupt"), ("unavailable", "network")]) +def test_post_login_validation(configured: CredentialStore, config_dir: Path, probe: sr.SessionProbe, + category: str) -> None: + with pytest.raises(RenewalError) as caught: + _renew(Validator(probe)) + assert caught.value.category == category + state = _state(config_dir) + assert state is not None + assert (state["blocked"], state["category"]) == (category != "network", category) + + +def test_validation_runs_inside_the_renewal_lock(configured: CredentialStore, sso: FakeSSO) -> None: + lock_was_held: list[bool] = [] + + def validate(_cookies: dict[str, str]) -> sr.SessionProbe: + try: + with renewal_lock(timeout=0): + pass + except RuntimeError: + lock_was_held.append(True) + else: + lock_was_held.append(False) + return "valid" + + result = _renew(validate) + assert result.signed_in is True + assert lock_was_held == [True] + + +def test_blocked_breaker_refuses_automatic_without_rewriting_state( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock +) -> None: + _fail_with(sso, MicrosoftSSOError("x", category="code_rejected")) + with pytest.raises(RenewalError): + _renew() + before = (config_dir / "relogin_state.json").read_bytes() + clock.now += 3600 + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "code_rejected" + assert len(sso.logins) == 1 + assert (config_dir / "relogin_state.json").read_bytes() == before + + +def test_explicit_login_ignores_breaker_and_clears_it_on_success( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock +) -> None: + _fail_with(sso, MicrosoftSSOError("x", category="credentials_rejected")) + with pytest.raises(RenewalError): + _renew() + sso.result = lambda: _cookies("fresh") + clock.now += 60 + assert _renew(automatic=False).signed_in is True + assert _state(config_dir) is None + + +def test_explicit_failure_while_blocked_keeps_the_block( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock +) -> None: + _fail_with(sso, MicrosoftSSOError("x", category="code_rejected")) + with pytest.raises(RenewalError): + _renew() + clock.now += 60 + _fail_with(sso, requests.ConnectionError("down")) + with pytest.raises(RenewalError): + _renew(automatic=False) + state = _state(config_dir) + assert state is not None and (state["blocked"], state["category"]) == (True, "code_rejected") + + +def test_repeated_failure_keeps_since_and_moves_last_attempt( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock +) -> None: + _fail_with(sso, MicrosoftSSOError("x", category="code_rejected")) + with pytest.raises(RenewalError): + _renew() + clock.now += 120 + with pytest.raises(RenewalError): + _renew(automatic=False) + state = _state(config_dir) + assert state is not None + assert state["since"] == "2027-01-15T08:00:15+00:00" + assert state["last_attempt"] == "2027-01-15T08:02:15+00:00" + + +def test_network_cooldown(configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock) -> None: + _fail_with(sso, requests.ConnectionError("down")) + with pytest.raises(RenewalError): + _renew() + clock.now += sr.NETWORK_COOLDOWN_SECONDS - 1 + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "network" + assert len(sso.logins) == 1 + clock.now += 2 + sso.result = lambda: _cookies("fresh") + assert _renew().signed_in is True + assert len(sso.logins) == 2 + + +def test_pending_two_step_login_blocks_automatic_only( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + (config_dir / "mfa_pending.json").write_text("{}") + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "pending_human_login" + assert sso.logins == [] + assert _state(config_dir) is None + assert _renew(automatic=False).signed_in is True + + +@pytest.mark.parametrize( + "content", + [ + "not json", + "[]", + '{"v": 2, "blocked": false, "category": null, "since": null, "last_attempt": null}', + '{"v": 1, "blocked": "no", "category": null, "since": null, "last_attempt": null}', + '{"v": 1, "blocked": false, "category": "code_rejected", "since": null, "last_attempt": null}', + '{"v": 1, "blocked": true, "category": null, "since": null, "last_attempt": null}', + '{"v": 1, "blocked": true, "category": "store", "since": null, "last_attempt": null}', + '{"v": 1, "blocked": false, "category": null, "since": "yesterday", "last_attempt": null}', + '{"v": 1, "blocked": false, "category": null, "since": "2027-01-01T00:00:00", "last_attempt": null}', + '{"v": 1, "blocked": false, "category": null, "since": "2027-01-01T00:00:00+05:30", "last_attempt": null}', + '{"v": 1, "blocked": false, "category": null, "since": null}', + '{"v": 1, "blocked": false, "category": null, "since": null, "last_attempt": null, "x": 1}', + '{"v": true, "blocked": false, "category": null, "since": null, "last_attempt": null}', + ], +) +def test_corrupt_state_fails_closed( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, content: str +) -> None: + (config_dir / "relogin_state.json").write_text(content) + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "unknown" + assert sso.logins == [] + assert unattended_status() == {"configured": True, "blocked": True} + + +def test_state_file_is_private_and_secret_free( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + _fail_with(sso, MicrosoftSSOError("x", category="code_rejected")) + with pytest.raises(RenewalError) as caught: + _renew() + path = config_dir / "relogin_state.json" + assert path.stat().st_mode & 0o777 == 0o600 + rendered = path.read_text() + str(caught.value) + repr(caught.value) + for secret in (PASSWORD, SECRET, USERNAME, sso.codes[0], "secure-fresh"): + assert secret not in rendered + + +def test_unknown_category_is_normalized() -> None: + assert RenewalError("nonsense").category == "unknown" + + +def test_unattended_configured_and_status(config_dir: Path) -> None: + assert unattended_configured() is False + store = CredentialStore() + store.save(USERNAME, PASSWORD) + assert unattended_configured() is False + store.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=None) + assert unattended_configured() is True + assert unattended_configured(config_dir.parent / "elsewhere") is False + assert unattended_status() == {"configured": True, "blocked": False} + + +# --------------------------------------------------------------------------- +# Code provider: one fresh step per sign-in +# --------------------------------------------------------------------------- + + +def test_provider_waits_for_the_next_step_after_a_used_one( + configured: CredentialStore, sso: FakeSSO, clock: Clock +) -> None: + configured.reserve_totp_step(STEP0) + _renew(automatic=False) + assert len(clock.sleeps) == 1 and 15 < clock.sleeps[0] <= 31 + stored = configured.load_totp() + assert stored is not None and stored.last_step == STEP0 + 1 + key = parse_totp_secret(SECRET).key + assert sso.codes == [totp_code(key, for_time=(STEP0 + 1) * 30)] + + +def test_provider_persists_reservation_before_generating_code( + configured: CredentialStore, sso: FakeSSO, monkeypatch: pytest.MonkeyPatch +) -> None: + original_totp_code = sr.totp_code + reserved_steps: list[int | None] = [] + + def inspect_reservation( + key: bytes, *, for_time: float, digits: int = 6, period: int = 30 + ) -> str: + stored = configured.load_totp() + reserved_steps.append(stored.last_step if stored is not None else None) + return original_totp_code(key, for_time=for_time, digits=digits, period=period) + + monkeypatch.setattr(sr, "totp_code", inspect_reservation) + _renew(automatic=False) + assert reserved_steps == [STEP0] + + +def test_provider_reports_clock_skew_when_no_fresh_step( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock +) -> None: + configured.reserve_totp_step(STEP0 + 10) + with pytest.raises(RenewalError) as caught: + _renew(automatic=False) + assert caught.value.category == "clock_skew" + assert clock.sleeps == [] + stored = configured.load_totp() + assert stored is not None and stored.last_step == STEP0 + 10 + state = _state(config_dir) + assert state is not None and state["category"] == "clock_skew" + + +def test_provider_reports_clock_skew_after_clock_rolls_back_during_wait( + configured: CredentialStore, sso: FakeSSO, clock: Clock, monkeypatch: pytest.MonkeyPatch +) -> None: + configured.reserve_totp_step(STEP0) + + def rollback_during_sleep(seconds: float) -> None: + clock.sleeps.append(seconds) + clock.now = T0 - 1 + + monkeypatch.setattr(sr, "_sleep", rollback_during_sleep) + with pytest.raises(RenewalError) as caught: + _renew(automatic=False) + + assert caught.value.category == "clock_skew" + assert len(clock.sleeps) == 1 and 15 < clock.sleeps[0] <= 31 + stored = configured.load_totp() + assert stored is not None and stored.last_step == STEP0 + assert sso.closed == 1 + + +def test_back_to_back_sign_ins_never_reuse_a_step(configured: CredentialStore, sso: FakeSSO) -> None: + _renew(automatic=False) + _renew(automatic=False) + assert len(set(sso.codes)) == 2 + stored = configured.load_totp() + assert stored is not None and stored.last_step == STEP0 + 1 + + +# --------------------------------------------------------------------------- +# Lock +# --------------------------------------------------------------------------- + + +def test_lock_is_not_reentrant(config_dir: Path) -> None: + with renewal_lock(), pytest.raises(RuntimeError): + with renewal_lock(): + pass + + +def test_lock_rejects_a_symlinked_lock_file(config_dir: Path, tmp_path: Path) -> None: + target = tmp_path / "elsewhere" + target.write_text("") + (config_dir / "relogin.lock").symlink_to(target) + with pytest.raises(RenewalError) as caught: + with renewal_lock(timeout=0.2): + pass + assert caught.value.category == "store" + + +def test_threads_share_one_sign_in(configured: CredentialStore, sso: FakeSSO) -> None: + sso.delay = 0.2 + results: list[RenewalResult] = [] + errors: list[BaseException] = [] + + def worker() -> None: + try: + results.append(_renew(Validator("valid"))) + except BaseException as exc: # pragma: no cover - surfaced below + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join(10) + assert errors == [] + assert sorted(r.signed_in for r in results) == [False, False, True] + assert len(sso.logins) == 1 + + +def test_thread_waiter_times_out_as_busy(config_dir: Path) -> None: + entered = threading.Event() + release = threading.Event() + + def holder() -> None: + with renewal_lock(): + entered.set() + release.wait(5) + + t = threading.Thread(target=holder) + t.start() + try: + assert entered.wait(5) + with pytest.raises(RenewalError) as caught: + with renewal_lock(timeout=0.2): + pass + assert caught.value.category == "busy" + finally: + release.set() + t.join(5) + + +_HOLDER = """ +import fcntl, os, sys +fd = os.open(sys.argv[1], os.O_CREAT | os.O_RDWR, 0o600) +fcntl.flock(fd, fcntl.LOCK_EX) +print("locked", flush=True) +sys.stdin.read() +""" + + +@pytest.fixture +def other_process_lock(config_dir: Path) -> Iterator[subprocess.Popen[str]]: + proc = subprocess.Popen( + [sys.executable, "-I", "-c", _HOLDER, str(config_dir / "relogin.lock")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + ) + try: + assert proc.stdout is not None + assert proc.stdout.readline().strip() == "locked" + yield proc + finally: + if proc.poll() is None: + proc.kill() + proc.wait(5) + + +def test_other_process_holding_the_lock_makes_renewal_busy( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, + other_process_lock: subprocess.Popen[str], monkeypatch: pytest.MonkeyPatch, +) -> None: + state_path = config_dir / "relogin_state.json" + state_path.write_text(json.dumps({ + "v": 1, "blocked": False, "category": "network", + "since": "2027-01-15T08:00:15+00:00", "last_attempt": "2027-01-15T08:00:15+00:00", + })) + before = state_path.read_bytes() + monkeypatch.setattr(sr, "_sleep", time.sleep) + monkeypatch.setattr(sr, "LOCK_TIMEOUT_SECONDS", 0.3) + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "busy" + assert sso.logins == [] + assert state_path.read_bytes() == before + + +def test_renewal_proceeds_once_the_other_process_releases( + configured: CredentialStore, sso: FakeSSO, + other_process_lock: subprocess.Popen[str], monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sr, "_sleep", time.sleep) + assert other_process_lock.stdin is not None + timer = threading.Timer(0.3, other_process_lock.stdin.close) + timer.start() + try: + assert _renew().signed_in is True + finally: + timer.cancel() + + +def test_lock_file_is_left_in_place_and_reusable(config_dir: Path) -> None: + with renewal_lock(): + pass + with renewal_lock(): + pass + assert (config_dir / "relogin.lock").is_file() + assert os.stat(config_dir / "relogin.lock").st_size == 0 diff --git a/tests/test_session_renewal_multiprocess.py b/tests/test_session_renewal_multiprocess.py new file mode 100644 index 0000000..0603993 --- /dev/null +++ b/tests/test_session_renewal_multiprocess.py @@ -0,0 +1,184 @@ +"""Real spawn-process coverage for unattended session renewal.""" + +from __future__ import annotations + +import json +import multiprocessing +import queue +import time +from pathlib import Path +from typing import Any + +import pytest + +from lighthouse_cli.config import load_cookies, save_cookies +from lighthouse_cli.credential_store import CredentialStore +from lighthouse_cli.totp import parse_totp_secret +from tests._relogin_mp_helper import WORKER_TIME, renewal_worker + +USERNAME = "worker@example.test" +PASSWORD = "test-password-only" # pragma: allowlist secret +TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" # pragma: allowlist secret +PASSPHRASE = "test-passphrase-only" +EXPIRED_COOKIES = { + "d2lSameSiteCanaryA": "canary-a-expired", + "d2lSameSiteCanaryB": "canary-b-expired", + "d2lSecureSessionVal": "secure-expired", + "d2lSessionVal": "session-expired", +} +FRESH_COOKIES = { + "d2lSameSiteCanaryA": "canary-a-fresh", + "d2lSameSiteCanaryB": "canary-b-fresh", + "d2lSecureSessionVal": "secure-fresh", + "d2lSessionVal": "session-fresh", +} + +WorkerSpec = tuple[ + str, + str, + str, + str, + dict[str, str], + dict[str, str], + str | None, + float, +] +WorkerResult = tuple[str, str, int] + + +def _seed_store(config_dir: Path, monkeypatch: pytest.MonkeyPatch) -> CredentialStore: + config_dir.mkdir(mode=0o700) + monkeypatch.setenv("LIGHTHOUSE_CONFIG_DIR", str(config_dir)) + monkeypatch.setenv("LIGHTHOUSE_SECRETS_PASSPHRASE", PASSPHRASE) + store = CredentialStore(config_dir=config_dir) + store.save(USERNAME, PASSWORD) + store.save_totp(USERNAME, parse_totp_secret(TOTP_SECRET), last_step=None) + save_cookies(EXPIRED_COOKIES) + return store + + +def _worker_spec( + label: str, + config_dir: Path, + counter_file: Path, + *, + failure_category: str | None = None, + delay_seconds: float = 0.0, +) -> WorkerSpec: + return ( + label, + str(config_dir), + PASSPHRASE, + str(counter_file), + EXPIRED_COOKIES, + FRESH_COOKIES, + failure_category, + delay_seconds, + ) + + +def _run_workers( + specs: list[WorkerSpec], *, start_together: bool +) -> dict[str, tuple[str, int]]: + context = multiprocessing.get_context("spawn") + result_queue: Any = context.Queue() + start_event: Any = context.Event() if start_together else None + processes: list[Any] = [] + deadline = time.monotonic() + 60 + results: list[WorkerResult] = [] + + try: + for spec in specs: + process = context.Process( + target=renewal_worker, + args=(*spec, start_event, result_queue), + ) + process.start() + processes.append(process) + if start_event is not None: + start_event.set() + + while len(results) < len(specs): + remaining = deadline - time.monotonic() + if remaining <= 0: + pytest.fail("Timed out after 60 seconds waiting for renewal worker results.") + try: + results.append(result_queue.get(timeout=min(1.0, remaining))) + except queue.Empty: + failed = [process for process in processes if process.exitcode not in (None, 0)] + if failed: + pytest.fail("A spawned renewal worker exited before reporting its outcome.") + + for process in processes: + process.join(timeout=max(0.0, deadline - time.monotonic())) + if process.is_alive(): + process.terminate() + process.join(timeout=5) + pytest.fail("A spawned renewal worker did not exit within 60 seconds.") + assert process.exitcode == 0, "A spawned renewal worker exited unsuccessfully." + + assert len(results) == len(specs) + assert len({label for label, _, _ in results}) == len(specs) + return {label: (outcome, provider_calls) for label, outcome, provider_calls in results} + finally: + for process in processes: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + result_queue.close() + result_queue.join_thread() + + +def test_two_processes_share_one_unattended_sign_in( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_dir = tmp_path / "config" + _seed_store(config_dir, monkeypatch) + counter_file = tmp_path / "login-count.txt" + results = _run_workers( + [ + _worker_spec("worker-a", config_dir, counter_file, delay_seconds=0.5), + _worker_spec("worker-b", config_dir, counter_file, delay_seconds=0.5), + ], + start_together=True, + ) + + assert sorted(outcome for outcome, _ in results.values()) == ["reused", "signed_in"] + assert sum(provider_calls for _, provider_calls in results.values()) == 1 + assert counter_file.read_text(encoding="utf-8").splitlines() == ["login"] + assert load_cookies(read_only=True) == FRESH_COOKIES + + +def test_blocked_breaker_prevents_second_process_and_totp_step_reuse( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_dir = tmp_path / "config" + store = _seed_store(config_dir, monkeypatch) + counter_file = tmp_path / "login-count.txt" + + first = _run_workers( + [ + _worker_spec( + "failing-worker", + config_dir, + counter_file, + failure_category="code_rejected", + ) + ], + start_together=False, + ) + assert first == {"failing-worker": ("error:code_rejected", 1)} + + second = _run_workers( + [_worker_spec("blocked-worker", config_dir, counter_file)], + start_together=False, + ) + assert second == {"blocked-worker": ("blocked:code_rejected", 0)} + assert counter_file.read_text(encoding="utf-8").splitlines() == ["login"] + + state = json.loads((config_dir / "relogin_state.json").read_text(encoding="utf-8")) + assert state["blocked"] is True + assert state["category"] == "code_rejected" + stored = store.load_totp() + assert stored is not None + assert stored.last_step == int(WORKER_TIME // stored.params.period) diff --git a/tests/test_totp.py b/tests/test_totp.py new file mode 100644 index 0000000..bc89190 --- /dev/null +++ b/tests/test_totp.py @@ -0,0 +1,157 @@ +"""RFC 6238 TOTP primitives: vectors, secret parsing and secret-safe errors.""" + +from __future__ import annotations + +import base64 + +import pytest + +from lighthouse_cli.totp import ( + TotpParams, + TotpSecretError, + parse_totp_secret, + time_step, + totp_code, +) + +# RFC 6238 Appendix B, SHA1 seed. +_RFC_KEY = b"12345678901234567890" +_RFC_BASE32 = base64.b32encode(_RFC_KEY).decode("ascii") +_SENTINEL_KEY = b"sentinel-key-material-20b" +_SENTINEL_BASE32 = base64.b32encode(_SENTINEL_KEY).decode("ascii").rstrip("=") + + +@pytest.mark.parametrize( + ("for_time", "expected"), + [ + (59, "94287082"), + (1111111109, "07081804"), + (1111111111, "14050471"), + (1234567890, "89005924"), + (2000000000, "69279037"), + (20000000000, "65353130"), + ], +) +def test_rfc6238_sha1_vectors(for_time: int, expected: str) -> None: + assert totp_code(_RFC_KEY, for_time=for_time, digits=8) == expected + + +@pytest.mark.parametrize( + ("counter", "expected"), + [(0, "755224"), (1, "287082"), (2, "359152"), (3, "969429"), (9, "520489")], +) +def test_six_digit_truncation_matches_rfc4226(counter: int, expected: str) -> None: + assert totp_code(_RFC_KEY, for_time=counter * 30 + 29.9) == expected + + +def test_time_step_floors_to_period() -> None: + assert time_step(59.999) == 1 + assert time_step(60) == 2 + assert time_step(0) == 0 + + +def test_rejects_unsupported_digits_and_pre_epoch_time() -> None: + with pytest.raises(ValueError): + totp_code(_RFC_KEY, for_time=0, digits=7) + with pytest.raises(ValueError): + totp_code(_RFC_KEY, for_time=-31) + + +@pytest.mark.parametrize( + "text", + [ + _RFC_BASE32, + _RFC_BASE32.lower(), + _RFC_BASE32.rstrip("="), + " ".join(_RFC_BASE32[i:i + 4] for i in range(0, 32, 4)), + "-".join(_RFC_BASE32[i:i + 4] for i in range(0, 32, 4)), + f" {_RFC_BASE32}\n", + ], +) +def test_bare_base32_is_normalized(text: str) -> None: + params = parse_totp_secret(text) + assert params.key == _RFC_KEY + assert (params.digits, params.period) == (6, 30) + assert params.secret_base32 == _RFC_BASE32.rstrip("=") + + +@pytest.mark.parametrize( + "uri", + [ + f"otpauth://totp/MAHE:student?secret={_RFC_BASE32}", + f"otpauth://totp/Label?secret={_RFC_BASE32}&issuer=Microsoft", + f"OTPAUTH://TOTP/Label?secret={_RFC_BASE32}&algorithm=sha1&digits=6&period=30", + ], +) +def test_otpauth_uri_is_accepted(uri: str) -> None: + assert parse_totp_secret(uri).key == _RFC_KEY + + +@pytest.mark.parametrize( + "uri", + [ + f"otpauth://hotp/Label?secret={_RFC_BASE32}&counter=1", + f"otpauth://totp/Label?secret={_RFC_BASE32}&algorithm=SHA256", + f"otpauth://totp/Label?secret={_RFC_BASE32}&digits=8", + f"otpauth://totp/Label?secret={_RFC_BASE32}&period=60", + f"otpauth://totp/Label?secret={_RFC_BASE32}&secret={_RFC_BASE32}", + f"otpauth://totp/Label?secret={_RFC_BASE32}&Digits=6&digits=6", + "otpauth://totp/Label?issuer=Microsoft", + "otpauth://totp/Label?secret", + ], +) +def test_otpauth_uri_rejections(uri: str) -> None: + with pytest.raises(TotpSecretError): + parse_totp_secret(uri) + + +@pytest.mark.parametrize( + "text", + [ + "", + " ", + "not base32 at all!", + "ABC1", # '1' is not in the base32 alphabet + "A", # impossible base32 length + base64.b32encode(b"short").decode("ascii"), + base64.b32encode(b"x" * 65).decode("ascii"), + "A" * 3000, + ], +) +def test_invalid_secrets_are_rejected(text: str) -> None: + with pytest.raises(TotpSecretError): + parse_totp_secret(text) + + +@pytest.mark.parametrize( + "text", + [ + f"{_SENTINEL_BASE32}!", + f"otpauth://totp/student@example.edu?secret={_SENTINEL_BASE32}&digits=8", + f"otpauth://hotp/student@example.edu?secret={_SENTINEL_BASE32}", + f"otpauth://totp/x?secret={_SENTINEL_BASE32}&secret={_SENTINEL_BASE32}", + f"otpauth://totp/x?secret={_SENTINEL_BASE32}&bad", + ], +) +def test_errors_never_echo_the_input(text: str) -> None: + with pytest.raises(TotpSecretError) as caught: + parse_totp_secret(text) + rendered = f"{caught.value!s} {caught.value!r}" + assert _SENTINEL_BASE32 not in rendered + assert _SENTINEL_BASE32[:12] not in rendered + assert "student@example.edu" not in rendered + + +def test_repr_hides_the_key() -> None: + params = parse_totp_secret(_SENTINEL_BASE32) + rendered = repr(params) + assert "key" not in rendered + assert _SENTINEL_BASE32 not in rendered + assert repr(_SENTINEL_KEY) not in rendered + assert rendered == "TotpParams(digits=6, period=30)" + + +def test_params_are_frozen() -> None: + params = TotpParams(key=_RFC_KEY) + with pytest.raises(AttributeError): + params.key = b"other" # type: ignore[misc] From 253a88ea23003a63a88fefef75604d1798e417e9 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:02:22 +0530 Subject: [PATCH 02/12] test: mark the second TOTP fixture key for gitleaks CI's gitleaks job flagged the OTHER_SECRET base32 fixture in tests/test_auth_totp.py as a generic API key. It is a test-only RFC 6238 style key, already allowlisted for detect-secrets; an inline `gitleaks:allow` now covers gitleaks too. --- tests/test_auth_totp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py index 96be9d4..154668a 100644 --- a/tests/test_auth_totp.py +++ b/tests/test_auth_totp.py @@ -29,7 +29,7 @@ USERNAME = "student@example.edu" PASSWORD = "pw-sentinel-Q7" # pragma: allowlist secret SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" # pragma: allowlist secret -OTHER_SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" # pragma: allowlist secret +OTHER_SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" # pragma: allowlist secret gitleaks:allow URI = f"otpauth://totp/Microsoft:{USERNAME}?secret={SECRET}&issuer=Microsoft" T0 = 1_800_000_015.0 # 15 s into a 30 s step STEP0 = int(T0 // 30) From b61af232d9c0e65fc9589b342841a713ed070352 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:02:29 +0530 Subject: [PATCH 03/12] fix(relogin): fail closed on breaker state and recover from a fast clock GPT-6.1 Sol found four ways the breaker could let automatic sign-in run again after a failure: - A failure that could not be recorded (unwritable config directory) was repeated on every read. Automatic sign-in now writes the breaker state before Microsoft sees the password, and stops with `store` if it can't. - A clock that went back ended the 5-minute `network` cooldown at once. A negative elapsed time now keeps it. - A state file whose category was a non-string (a list or object) raised instead of failing closed. - A `network` state missing its timestamps, or a clear state carrying them, was read as valid. Both now fail closed as `unknown`. CodeRabbit found that after a fast clock reserved a future time step, neither fix in the docs worked: `auth login` kept failing `clock_skew`, and `totp-setup` with the same key kept the future step. Setup now keeps a recorded step only up to two steps ahead of the clock, and the docs name `totp-setup` as the recovery for `code_rejected` and `clock_skew`. GLM 5.3 found that the docs said an unreadable state file is `unknown`; only corrupt content is, and a read failure is `store`. --- docs/auth-unattended.md | 11 +++--- lighthouse_cli/session_renewal.py | 19 +++++++++-- tests/test_auth_totp.py | 16 +++++++-- tests/test_session_renewal.py | 56 +++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/docs/auth-unattended.md b/docs/auth-unattended.md index 95f2d01..78d373e 100644 --- a/docs/auth-unattended.md +++ b/docs/auth-unattended.md @@ -121,13 +121,13 @@ Session expired; automatic sign-in failed (). Run: lighthouse auth log | Category | Meaning | Effect | Fix | |----------|---------|--------|-----| | `credentials_rejected` | Microsoft rejected the saved username or password | Blocked | See "Password change" below | -| `code_rejected` | Microsoft rejected the generated code | Blocked | Check the clock. If it is right, register the app again and run `auth totp-setup` | +| `code_rejected` | Microsoft rejected the generated code | Blocked | Check the clock. If it was wrong, fix it, run `auth totp-setup` again, then `auth login`. If it is right, register the app again and run `auth totp-setup` | | `method_unavailable` | The account offers no authenticator-app code method | Blocked | Register the app, then `auth totp-setup` | -| `clock_skew` | No unused time step is available (the clock went backwards) | Blocked | Fix the clock, then `auth login` | +| `clock_skew` | No unused time step is available (the clock went backwards) | Blocked | Fix the clock, run `auth totp-setup` again with the same secret, then `auth login`. It can report `clock_skew` for up to 90 seconds more | | `interrupt` | Microsoft showed a page that needs a person (for example a security-info prompt) | Blocked | Sign in once in a browser, then `auth login` | -| `unknown` | Anything else, including an unreadable state file | Blocked | `auth login --mfa-method choose` | +| `unknown` | Anything else, including a corrupt state file | Blocked | `auth login --mfa-method choose` | | `network` | Microsoft or D2L could not be reached | 5-minute wait between automatic attempts | Retry later | -| `busy`, `store`, `pending_human_login` | Another sign-in is running; local files unreadable; a two-step `auth login` is waiting for `auth verify` | Not recorded | Retry; check the key source; finish with `auth verify` | +| `busy`, `store`, `pending_human_login` | Another sign-in is running; local files unreadable, or the config directory not writable (checked before contacting Microsoft); a two-step `auth login` is waiting for `auth verify` | Not recorded | Retry; check the key source and the config directory permissions; finish with `auth verify` | **While blocked**, automatic sign-in is refused without contacting Microsoft. These clear the block: @@ -145,7 +145,8 @@ Codes depend on the system clock. Keep it synced with NTP (for example `timedatectl set-ntp true`, `systemd-timesyncd` or `chrony`). A clock that is more than about 30 seconds off gives `code_rejected`. A clock that goes back past the last step used gives `clock_skew`. Both block automatic sign-in until -you fix the clock and sign in. +you fix the clock, run `auth totp-setup` again, and sign in. Setup forgets a +step that a fast clock left more than a minute ahead. ## Password change diff --git a/lighthouse_cli/session_renewal.py b/lighthouse_cli/session_renewal.py index 0040efc..39eeeff 100644 --- a/lighthouse_cli/session_renewal.py +++ b/lighthouse_cli/session_renewal.py @@ -50,6 +50,9 @@ NETWORK_COOLDOWN_SECONDS = 300.0 _LOCK_POLL_SECONDS = 0.25 _STEP_MARGIN_SECONDS = 0.05 +#: Setup keeps a used step at most this far ahead of the clock. A step further +#: ahead came from a clock running fast, so its code was rejected. +_MAX_FUTURE_STEPS = 2 _LOCK_FILE = "relogin.lock" _STATE_FILE = "relogin_state.json" _STATE_VERSION = 1 @@ -229,7 +232,10 @@ def _state_from_document(doc: object) -> _BreakerState: if type(doc["v"]) is not int or doc["v"] != _STATE_VERSION or type(blocked) is not bool: return _CORRUPT_STATE allowed = BLOCKING_CATEGORIES if blocked else {None, "network"} - if category not in allowed: + if (category is not None and type(category) is not str) or category not in allowed: + return _CORRUPT_STATE + # A recorded failure has both timestamps; a clear state has neither. + if any((doc[key] is None) != (category is None) for key in ("since", "last_attempt")): return _CORRUPT_STATE try: for key in ("since", "last_attempt"): @@ -306,7 +312,8 @@ def store_authenticator(username: str, params: TotpParams) -> None: The current time step counts as used, so the code just typed to register the app is never sent again. Re-storing the same key keeps a newer - recorded step. + recorded step, unless a fast clock left it more than ``_MAX_FUTURE_STEPS`` + ahead: setup is how a person recovers from that. """ with renewal_lock(): store = CredentialStore() @@ -320,6 +327,7 @@ def store_authenticator(username: str, params: TotpParams) -> None: and previous.last_step is not None and previous.username.casefold() == username.casefold() and hmac.compare_digest(previous.params.key, params.key) + and previous.last_step <= last_step + _MAX_FUTURE_STEPS ): last_step = max(last_step, previous.last_step) store.save_totp(username, params, last_step=last_step) @@ -400,8 +408,9 @@ def _check_gates(store: CredentialStore, state: _BreakerState) -> None: if state.blocked: raise RenewalError(state.category or "unknown") if state.category == "network" and state.last_attempt: + # A clock that went back keeps the cooldown until it catches up. elapsed = (_utc_now() - _parse_utc(state.last_attempt)).total_seconds() - if 0 <= elapsed < NETWORK_COOLDOWN_SECONDS: + if elapsed < NETWORK_COOLDOWN_SECONDS: raise RenewalError("network") try: _validate_credential_path(store.mfa_pending_file) @@ -499,6 +508,10 @@ def _attempt( except RenewalError as exc: raise _UnrecordedError(exc) from None username, password, stored_totp = _usable_pair(credentials, stored) + if automatic: + # Prove the breaker is writable before Microsoft sees the password, so + # a failure that could not be recorded is not repeated on every read. + _write_state(store, state) cookies = _sign_in(store, username, password, stored_totp) save_cookies(cookies) probe = validate(dict(cookies)) diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py index 154668a..d2898b6 100644 --- a/tests/test_auth_totp.py +++ b/tests/test_auth_totp.py @@ -122,13 +122,25 @@ def test_setup_reserves_the_current_step(runner: CliRunner, saved: CredentialSto def test_setup_with_the_same_key_keeps_a_newer_step(runner: CliRunner, saved: CredentialStore) -> None: - saved.save_totp(USERNAME.upper(), parse_totp_secret(SECRET), last_step=STEP0 + 5) + saved.save_totp(USERNAME.upper(), parse_totp_secret(SECRET), last_step=STEP0 + 2) assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 stored = saved.load_totp() assert stored is not None - assert stored.last_step == STEP0 + 5 + assert stored.last_step == STEP0 + 2 + + +def test_setup_with_the_same_key_drops_a_step_from_a_fast_clock( + runner: CliRunner, saved: CredentialStore, +) -> None: + saved.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=STEP0 + 3) + + assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 + + stored = saved.load_totp() + assert stored is not None + assert stored.last_step == STEP0 def test_setup_with_a_new_key_starts_from_the_current_step(runner: CliRunner, saved: CredentialStore) -> None: diff --git a/tests/test_session_renewal.py b/tests/test_session_renewal.py index cc60ae4..f43249b 100644 --- a/tests/test_session_renewal.py +++ b/tests/test_session_renewal.py @@ -310,6 +310,24 @@ def test_store_failures_are_not_recorded(configured: CredentialStore, config_dir with pytest.raises(RenewalError) as caught: _renew() assert caught.value.category == "store" + state = _state(config_dir) + assert state is None or (state["blocked"], state["category"]) == (False, None) + + +@pytest.mark.parametrize("error", [PermissionError("read-only"), CredentialStoreError("unsafe")]) +def test_automatic_sign_in_needs_a_writable_breaker_first( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, + monkeypatch: pytest.MonkeyPatch, error: Exception, +) -> None: + def fail_write(*_args: object, **_kwargs: object) -> None: + raise error + + monkeypatch.setattr(sr, "atomic_write", fail_write) + for _ in range(2): + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "store" + assert sso.logins == [] assert _state(config_dir) is None @@ -413,6 +431,19 @@ def test_network_cooldown(configured: CredentialStore, config_dir: Path, sso: Fa assert len(sso.logins) == 2 +def test_network_cooldown_holds_when_the_clock_goes_back( + configured: CredentialStore, sso: FakeSSO, clock: Clock +) -> None: + _fail_with(sso, requests.ConnectionError("down")) + with pytest.raises(RenewalError): + _renew() + clock.now -= 60 + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "network" + assert len(sso.logins) == 1 + + def test_pending_two_step_login_blocks_automatic_only( configured: CredentialStore, config_dir: Path, sso: FakeSSO ) -> None: @@ -425,6 +456,9 @@ def test_pending_two_step_login_blocks_automatic_only( assert _renew(automatic=False).signed_in is True +_STAMP = "2027-01-15T08:00:15+00:00" + + @pytest.mark.parametrize( "content", [ @@ -441,6 +475,12 @@ def test_pending_two_step_login_blocks_automatic_only( '{"v": 1, "blocked": false, "category": null, "since": null}', '{"v": 1, "blocked": false, "category": null, "since": null, "last_attempt": null, "x": 1}', '{"v": true, "blocked": false, "category": null, "since": null, "last_attempt": null}', + f'{{"v": 1, "blocked": true, "category": [], "since": "{_STAMP}", "last_attempt": "{_STAMP}"}}', + f'{{"v": 1, "blocked": false, "category": {{}}, "since": "{_STAMP}", "last_attempt": "{_STAMP}"}}', + '{"v": 1, "blocked": false, "category": "network", "since": null, "last_attempt": null}', + f'{{"v": 1, "blocked": false, "category": "network", "since": "{_STAMP}", "last_attempt": null}}', + '{"v": 1, "blocked": true, "category": "code_rejected", "since": null, "last_attempt": null}', + f'{{"v": 1, "blocked": false, "category": null, "since": "{_STAMP}", "last_attempt": null}}', ], ) def test_corrupt_state_fails_closed( @@ -452,6 +492,8 @@ def test_corrupt_state_fails_closed( assert caught.value.category == "unknown" assert sso.logins == [] assert unattended_status() == {"configured": True, "blocked": True} + assert _renew(automatic=False).signed_in is True + assert _state(config_dir) is None def test_state_file_is_private_and_secret_free( @@ -531,6 +573,20 @@ def test_provider_reports_clock_skew_when_no_fresh_step( assert state is not None and state["category"] == "clock_skew" +def test_setup_again_recovers_from_a_step_reserved_by_a_fast_clock( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock +) -> None: + configured.reserve_totp_step(STEP0 + 100) + with pytest.raises(RenewalError) as caught: + _renew(automatic=False) + assert caught.value.category == "clock_skew" + sr.store_authenticator(USERNAME, parse_totp_secret(SECRET)) + assert _state(config_dir) is None + assert _renew().signed_in is True + key = parse_totp_secret(SECRET).key + assert sso.codes == [totp_code(key, for_time=(STEP0 + 1) * 30)] + + def test_provider_reports_clock_skew_after_clock_rolls_back_during_wait( configured: CredentialStore, sso: FakeSSO, clock: Clock, monkeypatch: pytest.MonkeyPatch ) -> None: From 0962d8bfaac0968bdc77daf53e406f2fd8cae048 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:13:37 +0530 Subject: [PATCH 04/12] docs(cli): say in auth login --help that a stored authenticator skips the chooser Grok 4.7 found that `auth login --help` still said a plain interactive login shows Microsoft's verification methods. With a matching stored authenticator, login uses it instead, with no chooser and no code prompt. The help for the command and for --mfa-method now says so. --- lighthouse_cli/cli.py | 8 +++++++- tests/test_auth_totp.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lighthouse_cli/cli.py b/lighthouse_cli/cli.py index cd65a46..454b88a 100644 --- a/lighthouse_cli/cli.py +++ b/lighthouse_cli/cli.py @@ -131,7 +131,8 @@ def auth_refresh(cdp_port: str | None, json_output: bool) -> None: help="Authenticator app code, or '-' to enter a fresh text code after it is sent.") @click.option("--mfa-method", type=click.Choice(["auto", "sms", "app", "call", "push", "choose"]), default=None, help=("MFA: sms, call, app, push, choose, or auto. A plain interactive login " - "shows the methods Microsoft reports for your account.")) + "shows the methods Microsoft reports for your account, unless a stored " + "authenticator is set up.")) @click.option("--save-credentials", "save_credentials", is_flag=True, default=False, help="Save email/password encrypted for future logins (session cookies still expire ~5 days).") @click.option("--json", "json_output", is_flag=True, help="Output this command's JSON result.") @@ -151,6 +152,11 @@ def auth_login(username: str | None, totp: str | None, mfa_method: str | None, s reports for your account, then enter the fresh code or approve the request. The command saves and verifies the session before showing useful next steps. + With a stored authenticator (lighthouse auth totp-setup), a login with no + --totp and no --mfa-method, or with --mfa-method app, uses it instead: no + method chooser and no code prompt. Any other --mfa-method, or a --totp, + keeps the steps above. + MFA flags remain available for scripts and explicit selection: auto, sms, call, app, push, or choose. Text codes may arrive via SMS or WhatsApp depending on Microsoft; the CLI cannot select the delivery channel. Voice diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py index d2898b6..dcbc65f 100644 --- a/tests/test_auth_totp.py +++ b/tests/test_auth_totp.py @@ -355,3 +355,12 @@ def test_setup_help_discloses_the_trade_off(runner: CliRunner) -> None: assert result.exit_code == 0 assert "Trade-off" in result.stdout assert "never accepted as a command-line argument" in " ".join(result.stdout.split()) + + +def test_login_help_explains_the_stored_authenticator(runner: CliRunner) -> None: + result = runner.invoke(cli, ["auth", "login", "--help"], catch_exceptions=False) + + assert result.exit_code == 0 + text = " ".join(result.stdout.split()) + assert "unless a stored authenticator is set up" in text + assert "With a stored authenticator (lighthouse auth totp-setup)" in text From 783702eb88c557fda51f430ad537709ebbcf56ad Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:42:26 +0530 Subject: [PATCH 05/12] ci: ignore the fake TOTP fixture key in the PR's first commit gitleaks scans every commit in the PR range, and 55e806b holds the OTHER_SECRET test fixture before 253a88e marked it `gitleaks:allow`. The key is a made-up base32 string for tests, so its fingerprint is ignored rather than rewriting the commit reviewers already saw. --- .gitleaksignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..5ebcf42 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1 @@ +55e806ba7338095a15f052f6a78a6700404d368b:tests/test_auth_totp.py:generic-api-key:32 From 34bf5cad6f874b560b1ffb6dad1e73900f14386e Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:07:54 +0530 Subject: [PATCH 06/12] fix(relogin): never resend a used time step, and block while signing in GPT-6.1 Sol found two ways the round-1 fixes let a sign-in repeat: - `totp-setup` with the same key dropped a recorded step more than two steps ahead of the clock. After a successful sign-in at step S and a clock that went back three steps, setup saved S-3, and once the clock was corrected the next login sent the code for S again. Grok 4.7, GLM 5.3 and DeepSeek V4.1 Flash found the same replay. Setup with the same key again keeps the newer step. The recovery from a fast clock is now `totp-remove`, then `totp-setup`, once the clock is right; the docs and the `clock_skew` login message (Grok 4.7) say so. - The breaker write before contacting Microsoft only proved the file was writable. A process killed while Microsoft had the password left the state clear, so the next read sent it again. Automatic sign-in now records itself as blocked (`unknown`) first, and the outcome replaces that: success clears it, a failure records its category, and a `store` failure restores the previous state. DeepSeek V4.1 Flash found the related case where recording the failure itself fails; the in-flight block now stays. CodeRabbit found that the docs' fix for `unknown` (`auth login --mfa-method choose`) never clears the block; it is now `auth login`. DeepSeek V4.1 Flash found that a fast clock can stretch the `network` cooldown; the docs now name `auth login` for that. --- docs/auth-unattended.md | 19 ++++-- lighthouse_cli/auth.py | 4 +- lighthouse_cli/session_renewal.py | 22 ++++--- tests/test_auth_totp.py | 20 ++---- tests/test_session_renewal.py | 103 +++++++++++++++++++++++++++++- 5 files changed, 134 insertions(+), 34 deletions(-) diff --git a/docs/auth-unattended.md b/docs/auth-unattended.md index 78d373e..147c344 100644 --- a/docs/auth-unattended.md +++ b/docs/auth-unattended.md @@ -121,12 +121,12 @@ Session expired; automatic sign-in failed (). Run: lighthouse auth log | Category | Meaning | Effect | Fix | |----------|---------|--------|-----| | `credentials_rejected` | Microsoft rejected the saved username or password | Blocked | See "Password change" below | -| `code_rejected` | Microsoft rejected the generated code | Blocked | Check the clock. If it was wrong, fix it, run `auth totp-setup` again, then `auth login`. If it is right, register the app again and run `auth totp-setup` | +| `code_rejected` | Microsoft rejected the generated code | Blocked | Check the clock. If it was wrong, see "Clock" below. If it is right, register the app again and run `auth totp-setup` | | `method_unavailable` | The account offers no authenticator-app code method | Blocked | Register the app, then `auth totp-setup` | -| `clock_skew` | No unused time step is available (the clock went backwards) | Blocked | Fix the clock, run `auth totp-setup` again with the same secret, then `auth login`. It can report `clock_skew` for up to 90 seconds more | +| `clock_skew` | No unused time step is available (the clock went back, or a fast clock used the steps ahead) | Blocked | See "Clock" below | | `interrupt` | Microsoft showed a page that needs a person (for example a security-info prompt) | Blocked | Sign in once in a browser, then `auth login` | -| `unknown` | Anything else, including a corrupt state file | Blocked | `auth login --mfa-method choose` | -| `network` | Microsoft or D2L could not be reached | 5-minute wait between automatic attempts | Retry later | +| `unknown` | Anything else, including a corrupt state file or an automatic sign-in that was interrupted (it counts as failed until it finishes) | Blocked | `auth login` | +| `network` | Microsoft or D2L could not be reached | 5-minute wait between automatic attempts | Retry later, or run `auth login` now. A clock that was fast when it failed stretches the wait by as much | | `busy`, `store`, `pending_human_login` | Another sign-in is running; local files unreadable, or the config directory not writable (checked before contacting Microsoft); a two-step `auth login` is waiting for `auth verify` | Not recorded | Retry; check the key source and the config directory permissions; finish with `auth verify` | **While blocked**, automatic sign-in is refused without contacting Microsoft. @@ -144,9 +144,14 @@ A login by SMS, call or push gives you a session but leaves the block in place. Codes depend on the system clock. Keep it synced with NTP (for example `timedatectl set-ntp true`, `systemd-timesyncd` or `chrony`). A clock that is more than about 30 seconds off gives `code_rejected`. A clock that goes back -past the last step used gives `clock_skew`. Both block automatic sign-in until -you fix the clock, run `auth totp-setup` again, and sign in. Setup forgets a -step that a fast clock left more than a minute ahead. +past the last step used gives `clock_skew`. Both block automatic sign-in. + +To recover, fix the clock, then run `auth login`. A time step is never used +twice, even after `auth totp-setup` with the same secret. So if a fast clock +used steps ahead, `auth login` keeps giving `clock_skew` until the clock +reaches them. To skip the wait, run `auth totp-remove`, then `auth totp-setup` +and `auth login`. Do that only once the clock is right: removing forgets which +steps were used. ## Password change diff --git a/lighthouse_cli/auth.py b/lighthouse_cli/auth.py index e362346..4a19bdf 100644 --- a/lighthouse_cli/auth.py +++ b/lighthouse_cli/auth.py @@ -145,7 +145,9 @@ class _HiddenInputUnavailableError(Exception): ), "clock_skew": ( "No fresh authenticator time step is available. " - "Check the system clock, then run: lighthouse auth login" + "Check the system clock, then run: lighthouse auth login. " + "If this repeats with the clock right, run lighthouse auth totp-remove " + "and lighthouse auth totp-setup first." ), "interrupt": ( "Microsoft sign-in stopped at a page that needs a person. " diff --git a/lighthouse_cli/session_renewal.py b/lighthouse_cli/session_renewal.py index 39eeeff..ffdeed6 100644 --- a/lighthouse_cli/session_renewal.py +++ b/lighthouse_cli/session_renewal.py @@ -50,9 +50,6 @@ NETWORK_COOLDOWN_SECONDS = 300.0 _LOCK_POLL_SECONDS = 0.25 _STEP_MARGIN_SECONDS = 0.05 -#: Setup keeps a used step at most this far ahead of the clock. A step further -#: ahead came from a clock running fast, so its code was rejected. -_MAX_FUTURE_STEPS = 2 _LOCK_FILE = "relogin.lock" _STATE_FILE = "relogin_state.json" _STATE_VERSION = 1 @@ -312,8 +309,8 @@ def store_authenticator(username: str, params: TotpParams) -> None: The current time step counts as used, so the code just typed to register the app is never sent again. Re-storing the same key keeps a newer - recorded step, unless a fast clock left it more than ``_MAX_FUTURE_STEPS`` - ahead: setup is how a person recovers from that. + recorded step, even one a fast clock reserved: only ``totp-remove`` + forgets used steps. """ with renewal_lock(): store = CredentialStore() @@ -327,7 +324,6 @@ def store_authenticator(username: str, params: TotpParams) -> None: and previous.last_step is not None and previous.username.casefold() == username.casefold() and hmac.compare_digest(previous.params.key, params.key) - and previous.last_step <= last_step + _MAX_FUTURE_STEPS ): last_step = max(last_step, previous.last_step) store.save_totp(username, params, last_step=last_step) @@ -509,9 +505,10 @@ def _attempt( raise _UnrecordedError(exc) from None username, password, stored_totp = _usable_pair(credentials, stored) if automatic: - # Prove the breaker is writable before Microsoft sees the password, so - # a failure that could not be recorded is not repeated on every read. - _write_state(store, state) + # Block until the outcome is known, so neither an unwritable breaker + # nor a process killed mid-sign-in lets every read send the password. + now = _utc_now().isoformat() + _write_state(store, _BreakerState(True, "unknown", now, now)) cookies = _sign_in(store, username, password, stored_totp) save_cookies(cookies) probe = validate(dict(cookies)) @@ -568,7 +565,12 @@ def renew_session( raise exc.error from None except Exception as exc: # every failure maps to a category error = _as_renewal_error(exc) - _record_failure(store, state, error.category) + if error.category not in _UNRECORDED_CATEGORIES: + _record_failure(store, state, error.category) + elif automatic: + # Undo the in-flight block; if that fails, it stays blocked. + with suppress(RenewalError): + _write_state(store, state) raise error from None if result.signed_in: clear_breaker() diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py index dcbc65f..3a00ceb 100644 --- a/tests/test_auth_totp.py +++ b/tests/test_auth_totp.py @@ -121,26 +121,16 @@ def test_setup_reserves_the_current_step(runner: CliRunner, saved: CredentialSto assert stored.last_step == STEP0 == time_step(T0, 30) -def test_setup_with_the_same_key_keeps_a_newer_step(runner: CliRunner, saved: CredentialStore) -> None: - saved.save_totp(USERNAME.upper(), parse_totp_secret(SECRET), last_step=STEP0 + 2) +@pytest.mark.parametrize("ahead", [2, 100]) +def test_setup_with_the_same_key_keeps_a_newer_step(runner: CliRunner, saved: CredentialStore, ahead: int) -> None: + """Even a step a fast clock reserved: forgetting it could resend an accepted code.""" + saved.save_totp(USERNAME.upper(), parse_totp_secret(SECRET), last_step=STEP0 + ahead) assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 stored = saved.load_totp() assert stored is not None - assert stored.last_step == STEP0 + 2 - - -def test_setup_with_the_same_key_drops_a_step_from_a_fast_clock( - runner: CliRunner, saved: CredentialStore, -) -> None: - saved.save_totp(USERNAME, parse_totp_secret(SECRET), last_step=STEP0 + 3) - - assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 - - stored = saved.load_totp() - assert stored is not None - assert stored.last_step == STEP0 + assert stored.last_step == STEP0 + ahead def test_setup_with_a_new_key_starts_from_the_current_step(runner: CliRunner, saved: CredentialStore) -> None: diff --git a/tests/test_session_renewal.py b/tests/test_session_renewal.py index f43249b..d1313f9 100644 --- a/tests/test_session_renewal.py +++ b/tests/test_session_renewal.py @@ -314,6 +314,93 @@ def test_store_failures_are_not_recorded(configured: CredentialStore, config_dir assert state is None or (state["blocked"], state["category"]) == (False, None) +def test_store_failure_restores_the_previous_state(configured: CredentialStore, config_dir: Path, sso: FakeSSO) -> None: + cooldown = { + "v": 1, "blocked": False, "category": "network", + "since": "2027-01-01T00:00:00+00:00", "last_attempt": "2027-01-01T00:00:00+00:00", + } + (config_dir / "relogin_state.json").write_text(json.dumps(cooldown)) + _fail_with(sso, CredentialStoreError("disk")) + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "store" + assert _state(config_dir) == cooldown + + +def _only_first_state_write_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + writes = 0 + real_write = sr.atomic_write + + def write_once(*args: Any, **kwargs: Any) -> None: + nonlocal writes + writes += 1 + if writes > 1: + raise PermissionError("read-only") + real_write(*args, **kwargs) + + monkeypatch.setattr(sr, "atomic_write", write_once) + + +def test_store_failure_stays_blocked_if_the_state_cannot_be_restored( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, monkeypatch: pytest.MonkeyPatch +) -> None: + _only_first_state_write_succeeds(monkeypatch) + _fail_with(sso, CredentialStoreError("disk")) + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "store" + state = _state(config_dir) + assert state is not None and (state["blocked"], state["category"]) == (True, "unknown") + + +def test_a_failure_that_cannot_be_recorded_stays_blocked( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, monkeypatch: pytest.MonkeyPatch +) -> None: + _only_first_state_write_succeeds(monkeypatch) + _fail_with(sso, MicrosoftSSOError("x", category="credentials_rejected")) + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "store" + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "unknown" + assert len(sso.logins) == 1 + + +def test_automatic_sign_in_is_recorded_as_blocked_while_it_runs( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + seen: list[dict[str, Any] | None] = [] + + def observe() -> dict[str, str]: + seen.append(_state(config_dir)) + return _cookies("fresh") + + sso.result = observe + assert _renew().signed_in is True + assert seen == [{ + "v": 1, "blocked": True, "category": "unknown", + "since": "2027-01-15T08:00:15+00:00", "last_attempt": "2027-01-15T08:00:15+00:00", + }] + assert _state(config_dir) is None + + +def test_an_interrupted_sign_in_blocks_the_next_automatic_one( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + """A process killed while Microsoft has the password never sends it again unattended.""" + _fail_with(sso, KeyboardInterrupt()) + with pytest.raises(KeyboardInterrupt): + _renew() + sso.result = lambda: _cookies("fresh") + with pytest.raises(RenewalError) as caught: + _renew() + assert caught.value.category == "unknown" + assert len(sso.logins) == 1 + assert _renew(automatic=False).signed_in is True + assert _state(config_dir) is None + + @pytest.mark.parametrize("error", [PermissionError("read-only"), CredentialStoreError("unsafe")]) def test_automatic_sign_in_needs_a_writable_breaker_first( configured: CredentialStore, config_dir: Path, sso: FakeSSO, @@ -573,13 +660,15 @@ def test_provider_reports_clock_skew_when_no_fresh_step( assert state is not None and state["category"] == "clock_skew" -def test_setup_again_recovers_from_a_step_reserved_by_a_fast_clock( +def test_remove_then_setup_recovers_from_a_step_reserved_by_a_fast_clock( configured: CredentialStore, config_dir: Path, sso: FakeSSO, clock: Clock ) -> None: configured.reserve_totp_step(STEP0 + 100) + sr.store_authenticator(USERNAME, parse_totp_secret(SECRET)) with pytest.raises(RenewalError) as caught: _renew(automatic=False) assert caught.value.category == "clock_skew" + assert sr.remove_authenticator() is True sr.store_authenticator(USERNAME, parse_totp_secret(SECRET)) assert _state(config_dir) is None assert _renew().signed_in is True @@ -587,6 +676,18 @@ def test_setup_again_recovers_from_a_step_reserved_by_a_fast_clock( assert sso.codes == [totp_code(key, for_time=(STEP0 + 1) * 30)] +def test_setup_after_a_clock_rollback_never_replays_an_accepted_step( + configured: CredentialStore, sso: FakeSSO, clock: Clock +) -> None: + assert _renew().signed_in is True + clock.now = T0 - 3 * 30 + sr.store_authenticator(USERNAME, parse_totp_secret(SECRET)) + clock.now = T0 + assert _renew(automatic=False).signed_in is True + key = parse_totp_secret(SECRET).key + assert sso.codes == [totp_code(key, for_time=T0), totp_code(key, for_time=(STEP0 + 1) * 30)] + + def test_provider_reports_clock_skew_after_clock_rolls_back_during_wait( configured: CredentialStore, sso: FakeSSO, clock: Clock, monkeypatch: pytest.MonkeyPatch ) -> None: From 56673b240d8637c0e9bce9cfd0e3226c515675b8 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:07:54 +0530 Subject: [PATCH 07/12] docs: say that only write requests skip sign-in, and qualify the login help Factory Droid found that the docs listed `submit` among the things that never sign in automatically, but the reads a write command makes first (course and folder lookup, the CSRF fetch) are ordinary reads and can. That is the reviewed design: the rule is per request, and a write is never sent twice. The docs now say so. CodeRabbit found that `auth login --help` left out that the stored authenticator is used only for the username it is bound to. --- docs/auth-unattended.md | 9 ++++++--- lighthouse_cli/cli.py | 10 +++++----- tests/test_auth_totp.py | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/auth-unattended.md b/docs/auth-unattended.md index 147c344..8e3fdfa 100644 --- a/docs/auth-unattended.md +++ b/docs/auth-unattended.md @@ -38,12 +38,15 @@ factor. **What never signs in automatically:** -- uploads and other writes (`submit`, quiz answers, any POST/PUT/DELETE); +- write requests: uploads, quiz answers, any POST/PUT/DELETE; - reads marked unsafe to repeat, and the quiz-attempt flows; - connections other than the built-in Lighthouse one. -These fail with "Session expired" as before. Run `lighthouse auth login`, or -any read command, to sign in. +These fail with "Session expired" as before, and a write is never sent twice. +Run `lighthouse auth login`, or any read command, to sign in. The reads a write +command makes first, such as `submit` looking up the course and folder, are +ordinary reads: they can sign in, and the write then runs once with the new +session. ## Setup diff --git a/lighthouse_cli/cli.py b/lighthouse_cli/cli.py index 454b88a..da8f3d8 100644 --- a/lighthouse_cli/cli.py +++ b/lighthouse_cli/cli.py @@ -132,7 +132,7 @@ def auth_refresh(cdp_port: str | None, json_output: bool) -> None: @click.option("--mfa-method", type=click.Choice(["auto", "sms", "app", "call", "push", "choose"]), default=None, help=("MFA: sms, call, app, push, choose, or auto. A plain interactive login " "shows the methods Microsoft reports for your account, unless a stored " - "authenticator is set up.")) + "authenticator is set up for that username.")) @click.option("--save-credentials", "save_credentials", is_flag=True, default=False, help="Save email/password encrypted for future logins (session cookies still expire ~5 days).") @click.option("--json", "json_output", is_flag=True, help="Output this command's JSON result.") @@ -152,10 +152,10 @@ def auth_login(username: str | None, totp: str | None, mfa_method: str | None, s reports for your account, then enter the fresh code or approve the request. The command saves and verifies the session before showing useful next steps. - With a stored authenticator (lighthouse auth totp-setup), a login with no - --totp and no --mfa-method, or with --mfa-method app, uses it instead: no - method chooser and no code prompt. Any other --mfa-method, or a --totp, - keeps the steps above. + With a stored authenticator (lighthouse auth totp-setup) for the same + username, a login with no --totp and no --mfa-method, or with --mfa-method + app, uses it instead: no method chooser and no code prompt. Any other + --mfa-method, or a --totp, keeps the steps above. MFA flags remain available for scripts and explicit selection: auto, sms, call, app, push, or choose. Text codes may arrive via SMS or WhatsApp diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py index 3a00ceb..ad21aaa 100644 --- a/tests/test_auth_totp.py +++ b/tests/test_auth_totp.py @@ -352,5 +352,5 @@ def test_login_help_explains_the_stored_authenticator(runner: CliRunner) -> None assert result.exit_code == 0 text = " ".join(result.stdout.split()) - assert "unless a stored authenticator is set up" in text - assert "With a stored authenticator (lighthouse auth totp-setup)" in text + assert "unless a stored authenticator is set up for that username" in text + assert "With a stored authenticator (lighthouse auth totp-setup) for the same username" in text From ba2abbdb9af921aa66c820904f61a33e486e53e3 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:30:55 +0530 Subject: [PATCH 08/12] fix(relogin): never let totp-setup forget used time steps GPT-6.1 Sol found that `totp-setup` treated an authenticator file it could not read as absent. After a sign-in at step S and a clock that went back three steps, a corrupted or foreign-key `authenticator.json` was replaced with S-3, and once the clock was corrected the next login sent the code for S again. Setup now refuses to replace a file it cannot read and names `totp-remove`, then `totp-setup`. DeepSeek V4.1 Flash found that setup kept a newer step only when the saved username was unchanged. Re-storing the same key under an alias dropped it the same way. The step now follows the key, whatever the username. --- docs/auth-unattended.md | 2 +- lighthouse_cli/auth.py | 2 ++ lighthouse_cli/session_renewal.py | 13 +++++++++---- tests/test_auth_totp.py | 30 +++++++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/auth-unattended.md b/docs/auth-unattended.md index 8e3fdfa..0406a99 100644 --- a/docs/auth-unattended.md +++ b/docs/auth-unattended.md @@ -106,7 +106,7 @@ than one authenticator-app method, and the CLI cannot choose between them. | Command | What it does | |---------|--------------| -| `lighthouse auth totp-setup [--json]` | Store the authenticator secret (hidden prompt or stdin). Needs saved credentials. Clears the breaker. JSON: `{"success": true, "configured": true}` | +| `lighthouse auth totp-setup [--json]` | Store the authenticator secret (hidden prompt or stdin). Needs saved credentials. Clears the breaker. Refuses to replace a stored secret it cannot read; run `totp-remove` first. JSON: `{"success": true, "configured": true}` | | `lighthouse auth totp-remove [--json]` | Delete the stored secret and the breaker state. Saved credentials and cookies stay. JSON: `{"success": true, "removed": true}` (`false` if nothing was stored) | | `lighthouse auth login [--json]` | With the stored authenticator configured, signs in with no prompt. Any `--totp`, or `--mfa-method sms\|call\|push\|choose\|auto`, keeps the person-driven flow | | `lighthouse auth status [--json]` | Signs in again if needed, like any read. Reports `renewed` and `unattended_login: {configured, blocked}` | diff --git a/lighthouse_cli/auth.py b/lighthouse_cli/auth.py index 4a19bdf..4d3b2a0 100644 --- a/lighthouse_cli/auth.py +++ b/lighthouse_cli/auth.py @@ -53,6 +53,7 @@ ) from .ms_mfa import format_user_proof, safe_auth_method_id from .session_renewal import ( + _UNREADABLE_AUTHENTICATOR, RenewalError, RenewalUnavailableError, remove_authenticator, @@ -179,6 +180,7 @@ class _HiddenInputUnavailableError(Exception): _HIDDEN_INPUT_UNAVAILABLE, _NO_MATCHING_AUTHENTICATOR, _INVALID_TOTP_MSG, + _UNREADABLE_AUTHENTICATOR, *_STORED_LOGIN_FAILURES.values(), *SECRET_ERROR_MESSAGES, }) diff --git a/lighthouse_cli/session_renewal.py b/lighthouse_cli/session_renewal.py index ffdeed6..3f65b42 100644 --- a/lighthouse_cli/session_renewal.py +++ b/lighthouse_cli/session_renewal.py @@ -75,6 +75,11 @@ "pending_human_login": "A two-step sign-in is waiting for verification.", "unknown": "Automatic sign-in failed unexpectedly.", } +# Setup cannot keep the used steps of an authenticator it cannot read. +_UNREADABLE_AUTHENTICATOR = ( + "The stored authenticator could not be read. Check the system clock, " + "then run: lighthouse auth totp-remove and lighthouse auth totp-setup" +) # Indirection so tests can drive the clock without patching ``time``. _clock: Callable[[], float] = time.time @@ -309,8 +314,9 @@ def store_authenticator(username: str, params: TotpParams) -> None: The current time step counts as used, so the code just typed to register the app is never sent again. Re-storing the same key keeps a newer - recorded step, even one a fast clock reserved: only ``totp-remove`` - forgets used steps. + recorded step, even one a fast clock reserved or one used under another + username, and an authenticator that cannot be read is never replaced: + only ``totp-remove`` forgets used steps. """ with renewal_lock(): store = CredentialStore() @@ -318,11 +324,10 @@ def store_authenticator(username: str, params: TotpParams) -> None: try: previous = store.load_totp() except CredentialStoreError: - previous = None # replaced below + raise CredentialStoreError(_UNREADABLE_AUTHENTICATOR) from None if ( previous is not None and previous.last_step is not None - and previous.username.casefold() == username.casefold() and hmac.compare_digest(previous.params.key, params.key) ): last_step = max(last_step, previous.last_step) diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py index ad21aaa..7d1597e 100644 --- a/tests/test_auth_totp.py +++ b/tests/test_auth_totp.py @@ -122,9 +122,13 @@ def test_setup_reserves_the_current_step(runner: CliRunner, saved: CredentialSto @pytest.mark.parametrize("ahead", [2, 100]) -def test_setup_with_the_same_key_keeps_a_newer_step(runner: CliRunner, saved: CredentialStore, ahead: int) -> None: - """Even a step a fast clock reserved: forgetting it could resend an accepted code.""" - saved.save_totp(USERNAME.upper(), parse_totp_secret(SECRET), last_step=STEP0 + ahead) +@pytest.mark.parametrize("bound_to", [USERNAME.upper(), "alias@example.edu"]) +def test_setup_with_the_same_key_keeps_a_newer_step( + runner: CliRunner, saved: CredentialStore, ahead: int, bound_to: str, +) -> None: + """Even a step a fast clock reserved, or one used under another username: + forgetting it could resend an accepted code.""" + saved.save_totp(bound_to, parse_totp_secret(SECRET), last_step=STEP0 + ahead) assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 @@ -144,6 +148,26 @@ def test_setup_with_a_new_key_starts_from_the_current_step(runner: CliRunner, sa assert stored.last_step == STEP0 +def test_setup_never_replaces_an_authenticator_it_cannot_read(runner: CliRunner, saved: CredentialStore) -> None: + """Its used steps are unknown, so replacing it could resend an accepted code.""" + saved.authenticator_file.write_text("{", encoding="utf-8") + + refused = _setup(runner, f"{SECRET}\n", "--json") + + assert refused.exit_code == 1 + assert json.loads(refused.stdout) == { + "success": False, "error": session_renewal._UNREADABLE_AUTHENTICATOR, + } + assert saved.authenticator_file.read_text(encoding="utf-8") == "{" + _assert_no_secret(refused) + + removed = runner.invoke(cli, ["auth", "totp-remove", "--json"], catch_exceptions=False) + assert removed.exit_code == 0 + assert _setup(runner, f"{SECRET}\n", "--json").exit_code == 0 + stored = saved.load_totp() + assert stored is not None and stored.last_step == STEP0 + + def test_setup_clears_the_breaker(runner: CliRunner, saved: CredentialStore, config_dir: Path) -> None: state = config_dir / "relogin_state.json" state.write_text(json.dumps({"v": 1, "blocked": True, "category": "code_rejected"})) From 6256fe09c870b310aac608ac4436a52fb22d204a Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:30:55 +0530 Subject: [PATCH 09/12] fix(auth): point a failed stored login at the login that clears the block GLM 5.3, Grok 4.7, Muse Spark 1.3 and DeepSeek V4.1 Flash found that an `unknown` stored-login failure still said to run `auth login --mfa-method choose`. That login gives a session but leaves automatic sign-in blocked; round 2 fixed the same advice in the docs only. The message now says to run `auth login` again, and that the chooser leaves automatic sign-in blocked. GLM 5.3 found that the docs called `store` failures unrecorded, but one during an automatic sign-in can leave the in-flight `unknown` block in place. The docs now say so. --- docs/auth-unattended.md | 2 +- lighthouse_cli/auth.py | 4 +++- tests/test_auth_login.py | 8 ++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/auth-unattended.md b/docs/auth-unattended.md index 0406a99..991162c 100644 --- a/docs/auth-unattended.md +++ b/docs/auth-unattended.md @@ -130,7 +130,7 @@ Session expired; automatic sign-in failed (). Run: lighthouse auth log | `interrupt` | Microsoft showed a page that needs a person (for example a security-info prompt) | Blocked | Sign in once in a browser, then `auth login` | | `unknown` | Anything else, including a corrupt state file or an automatic sign-in that was interrupted (it counts as failed until it finishes) | Blocked | `auth login` | | `network` | Microsoft or D2L could not be reached | 5-minute wait between automatic attempts | Retry later, or run `auth login` now. A clock that was fast when it failed stretches the wait by as much | -| `busy`, `store`, `pending_human_login` | Another sign-in is running; local files unreadable, or the config directory not writable (checked before contacting Microsoft); a two-step `auth login` is waiting for `auth verify` | Not recorded | Retry; check the key source and the config directory permissions; finish with `auth verify` | +| `busy`, `store`, `pending_human_login` | Another sign-in is running; local files unreadable, or the config directory not writable (checked before contacting Microsoft); a two-step `auth login` is waiting for `auth verify` | Not recorded. A `store` failure during an automatic sign-in can leave it blocked as `unknown` | Retry; check the key source and the config directory permissions; finish with `auth verify` | **While blocked**, automatic sign-in is refused without contacting Microsoft. These clear the block: diff --git a/lighthouse_cli/auth.py b/lighthouse_cli/auth.py index 4d3b2a0..926dd1b 100644 --- a/lighthouse_cli/auth.py +++ b/lighthouse_cli/auth.py @@ -169,7 +169,9 @@ class _HiddenInputUnavailableError(Exception): ), "unknown": ( "Sign-in with the stored authenticator failed unexpectedly. " - "Run: lighthouse auth login --mfa-method choose" + "Run: lighthouse auth login. If it fails again, use " + "lighthouse auth login --mfa-method choose; automatic sign-in stays " + "blocked until lighthouse auth login works with the stored authenticator." ), } # First-party fixed messages shown verbatim: the generic filter below would diff --git a/tests/test_auth_login.py b/tests/test_auth_login.py index c33303e..69a7685 100644 --- a/tests/test_auth_login.py +++ b/tests/test_auth_login.py @@ -1647,6 +1647,14 @@ def test_every_category_has_a_stored_login_message() -> None: assert set(auth_mod._STORED_LOGIN_FAILURES) == RELOGIN_CATEGORIES +def test_unknown_stored_login_failure_names_the_login_that_clears_the_block() -> None: + """A chooser login signs in but leaves automatic sign-in blocked.""" + message = auth_mod._STORED_LOGIN_FAILURES["unknown"] + + assert "Run: lighthouse auth login." in message + assert "automatic sign-in stays blocked until lighthouse auth login works with the stored authenticator" in message + + @pytest.mark.parametrize("category", sorted(RELOGIN_CATEGORIES)) def test_stored_login_failure_renders_one_fixed_document( cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], category: str, From e097ba10e566706f854b6ba69976af36a017dad9 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:25:56 +0530 Subject: [PATCH 10/12] fix(relogin): keep ordinary commands working without fcntl Factory Droid found that `api.py` now imports `session_renewal`, which imported `fcntl` at module level. On a system without it, such as Windows, ordinary commands like `courses --json` died with a raw `ModuleNotFoundError`; on main they ran. The login cookie save, `import-session`, the CDP refresh save and the legacy cookie migration also take the sign-in lock, whose lock file needs POSIX-only open flags. `fcntl` is now optional. Without it the lock covers only threads, and stored-authenticator sign-in is off: `totp-setup` refuses before reading the secret, `auth login` keeps the person-driven flow, automatic sign-in is never configured, and `renew_session` refuses before doing anything. The docs' setup section says so. --- docs/auth-unattended.md | 4 ++- lighthouse_cli/auth.py | 7 ++++ lighthouse_cli/session_renewal.py | 56 ++++++++++++++++++++++++------- tests/test_auth_login.py | 17 ++++++++++ tests/test_auth_totp.py | 12 +++++++ tests/test_session_renewal.py | 54 +++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 13 deletions(-) diff --git a/docs/auth-unattended.md b/docs/auth-unattended.md index 991162c..eaecff8 100644 --- a/docs/auth-unattended.md +++ b/docs/auth-unattended.md @@ -51,7 +51,9 @@ session. ## Setup You need an encryption key source (`LIGHTHOUSE_SECRETS_PASSPHRASE` or the OS -keyring; see "Encryption key sources" in the README). +keyring; see "Encryption key sources" in the README), and a POSIX system such +as Linux or macOS, which can keep sign-ins one at a time across processes. +Elsewhere `totp-setup` refuses and the other commands work as before. 1. Sign in once and save your username and password: diff --git a/lighthouse_cli/auth.py b/lighthouse_cli/auth.py index 926dd1b..2f18c69 100644 --- a/lighthouse_cli/auth.py +++ b/lighthouse_cli/auth.py @@ -60,6 +60,7 @@ renew_session, renewal_lock, store_authenticator, + unattended_supported, ) from .totp import SECRET_ERROR_MESSAGES, TotpSecretError, parse_totp_secret @@ -126,6 +127,7 @@ class _HiddenInputUnavailableError(Exception): _SIGN_IN_BUSY = "Another sign-in is in progress. Run the command again." _SIGN_IN_STORE = str(RenewalError("store")) _NO_SAVED_CREDENTIALS = "No saved credentials. Run: lighthouse auth login --save-credentials" +_UNATTENDED_UNSUPPORTED = "Unattended sign-in needs a POSIX system such as Linux or macOS." _HIDDEN_INPUT_UNAVAILABLE = ( "Cannot hide input on this terminal. Pipe the secret on stdin instead." ) @@ -179,6 +181,7 @@ class _HiddenInputUnavailableError(Exception): _SAFE_FIXED_AUTH_MESSAGES = frozenset({ _SIGN_IN_STORE, _NO_SAVED_CREDENTIALS, + _UNATTENDED_UNSUPPORTED, _HIDDEN_INPUT_UNAVAILABLE, _NO_MATCHING_AUTHENTICATOR, _INVALID_TOTP_MSG, @@ -1105,6 +1108,8 @@ def _on_password_accepted() -> None: def _stored_authenticator_matches(username: str) -> bool: """Whether a readable stored authenticator is bound to ``username``.""" + if not unattended_supported(): + return False try: stored = CredentialStore().load_totp() except CredentialStoreError as exc: @@ -1183,6 +1188,8 @@ def cmd_auth_totp_setup(*, json_output: bool = False) -> int: The secret is bound to the saved username and sealed like the password. It is never printed, and neither is a code generated from it. """ + if not unattended_supported(): + return _auth_error(_UNATTENDED_UNSUPPORTED, json_output) ensure_config_dir() store = CredentialStore() store.preflight() diff --git a/lighthouse_cli/session_renewal.py b/lighthouse_cli/session_renewal.py index 3f65b42..4b9558d 100644 --- a/lighthouse_cli/session_renewal.py +++ b/lighthouse_cli/session_renewal.py @@ -4,7 +4,9 @@ Microsoft SSO using the stored password and a generated authenticator (PhoneAppOTP) code, saves the new cookies and checks them. Everything runs under one sign-in lock shared by threads and processes, so concurrent -callers reuse a single renewal instead of each signing in. +callers reuse a single renewal instead of each signing in. Without ``fcntl`` +(not POSIX) the lock covers only threads, so stored-authenticator sign-in is +off there. A circuit breaker in ``relogin_state.json`` (plaintext, never secret) stops automatic attempts after an authentication-class failure until a person signs @@ -16,7 +18,6 @@ from __future__ import annotations -import fcntl import hmac import json import os @@ -32,6 +33,13 @@ import requests +try: + import fcntl +except ImportError: # not POSIX: other processes cannot be locked out + _HAS_FLOCK = False +else: + _HAS_FLOCK = True + from .config import COOKIE_NAMES, load_cookies, missing_cookie_names, save_cookies from .credential_store import ( CredentialStore, @@ -175,10 +183,25 @@ def _flock_until(descriptor: int, deadline: float) -> None: raise RenewalError("store") from None +@contextmanager +def _process_lock(deadline: float) -> Iterator[None]: + if not _HAS_FLOCK: + # Stored-authenticator sign-in is off here, so only this process's + # cookie writes need serializing. + yield + return + descriptor = _open_lock_file(CredentialStore()) + try: + _flock_until(descriptor, deadline) + yield + finally: + os.close(descriptor) + + @contextmanager def renewal_lock(*, timeout: float | None = None) -> Iterator[None]: """Hold the sign-in lock: a process-wide thread lock, then an exclusive - ``flock`` on ``/relogin.lock``. + ``flock`` on ``/relogin.lock`` where ``fcntl`` exists. One monotonic deadline (default ``LOCK_TIMEOUT_SECONDS``) covers both. Raises ``RenewalError("busy")`` on timeout and ``RenewalError("store")`` @@ -193,16 +216,12 @@ def renewal_lock(*, timeout: float | None = None) -> Iterator[None]: if not _THREAD_LOCK.acquire(timeout=max(0.0, timeout)): raise RenewalError("busy") try: - descriptor = _open_lock_file(CredentialStore()) - try: - _flock_until(descriptor, deadline) + with _process_lock(deadline): _owner.held = True try: yield finally: _owner.held = False - finally: - os.close(descriptor) finally: _THREAD_LOCK.release() @@ -343,14 +362,24 @@ def remove_authenticator() -> bool: return removed +def unattended_supported() -> bool: + """Whether sign-ins can be kept one at a time across processes (POSIX).""" + return _HAS_FLOCK + + def unattended_configured(config_dir: Path | None = None) -> bool: - """Cheap check: a stored authenticator and saved credentials both exist. + """Cheap check: supported here, and a stored authenticator and saved + credentials both exist. No decryption and no network. """ store = CredentialStore(config_dir=config_dir) try: - return store.authenticator_file.is_file() and store.credentials_file.is_file() + return ( + unattended_supported() + and store.authenticator_file.is_file() + and store.credentials_file.is_file() + ) except OSError: return False @@ -547,10 +576,13 @@ def renew_session( persist_credentials: Save ``credentials`` after a validated sign-in. Raises: - RenewalUnavailableError: No stored authenticator matches the credentials; - nothing was attempted. + RenewalUnavailableError: No stored authenticator matches the credentials, + or this system cannot lock out other processes; nothing was + attempted. RenewalError: Fixed-text failure with a ``category``. """ + if not unattended_supported(): + raise RenewalUnavailableError() with renewal_lock(): store = CredentialStore() state = _load_state(store) diff --git a/tests/test_auth_login.py b/tests/test_auth_login.py index 69a7685..8e78daa 100644 --- a/tests/test_auth_login.py +++ b/tests/test_auth_login.py @@ -1582,6 +1582,23 @@ def test_unreadable_stored_authenticator_warns_and_keeps_the_person_driven_flow( sso.login.assert_not_called() +def test_without_flock_login_keeps_the_person_driven_flow( + cli_runner: CliRunner, + stored_authenticator: tuple[MagicMock, MagicMock], + monkeypatch: pytest.MonkeyPatch, +) -> None: + sso, _probe = stored_authenticator + monkeypatch.setattr(session_renewal, "_HAS_FLOCK", False) + + with _mock_sso() as (human_sso, _human_probe): + result = _invoke_login(cli_runner, ["--json"]) + + assert result.exit_code == 0 + assert STORED_LOGIN_NOTICE not in result.stderr + human_sso.login.assert_called_once() + sso.login.assert_not_called() + + def test_valid_cookies_on_disk_do_not_skip_an_explicit_login( cli_runner: CliRunner, stored_authenticator: tuple[MagicMock, MagicMock], ) -> None: diff --git a/tests/test_auth_totp.py b/tests/test_auth_totp.py index 7d1597e..2b64bd1 100644 --- a/tests/test_auth_totp.py +++ b/tests/test_auth_totp.py @@ -259,6 +259,18 @@ def test_setup_without_saved_credentials_reads_nothing(runner: CliRunner, config assert not (config_dir / "authenticator.json").exists() +def test_setup_without_flock_refuses_before_reading( + runner: CliRunner, saved: CredentialStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(session_renewal, "_HAS_FLOCK", False) + + result = _setup(runner, _UnreadableStdin(), "--json") + + assert result.exit_code == 1 + assert json.loads(result.stdout) == {"success": False, "error": auth_mod._UNATTENDED_UNSUPPORTED} + assert saved.load_totp() is None + + def test_setup_busy_stores_nothing( runner: CliRunner, saved: CredentialStore, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_session_renewal.py b/tests/test_session_renewal.py index d1313f9..3191aef 100644 --- a/tests/test_session_renewal.py +++ b/tests/test_session_renewal.py @@ -737,6 +737,60 @@ def test_lock_rejects_a_symlinked_lock_file(config_dir: Path, tmp_path: Path) -> assert caught.value.category == "store" +_WITHOUT_FCNTL = """ +import sys +sys.modules["fcntl"] = None # importing it fails, as on Windows +from lighthouse_cli import api, auth, cli, commands, session_import, session_renewal +print(session_renewal.unattended_supported()) +""" + + +def test_modules_import_without_fcntl() -> None: + result = subprocess.run( + [sys.executable, "-I", "-c", _WITHOUT_FCNTL], + capture_output=True, text=True, timeout=60, check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "False" + + +@pytest.fixture +def no_flock(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sr, "_HAS_FLOCK", False) + + +def test_without_flock_the_lock_still_serializes_threads(config_dir: Path, no_flock: None) -> None: + entered, release = threading.Event(), threading.Event() + + def holder() -> None: + with renewal_lock(): + entered.set() + release.wait(5) + + t = threading.Thread(target=holder) + t.start() + try: + assert entered.wait(5) + with pytest.raises(RenewalError) as caught: + with renewal_lock(timeout=0.2): + pass + assert caught.value.category == "busy" + finally: + release.set() + t.join(5) + assert not (config_dir / "relogin.lock").exists() + + +def test_without_flock_stored_authenticator_sign_in_is_off( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, no_flock: None, +) -> None: + assert unattended_configured() is False + with pytest.raises(RenewalUnavailableError): + _renew(Validator("valid")) + assert sso.logins == [] + assert _state(config_dir) is None + + def test_threads_share_one_sign_in(configured: CredentialStore, sso: FakeSSO) -> None: sso.delay = 0.2 results: list[RenewalResult] = [] From 4db986f85e732af080a60e25282ea68680188c00 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:25:56 +0530 Subject: [PATCH 11/12] fix(display): keep the sign-in category when an error is formatted twice Factory Droid found that `format_user_error` applied to its own output dropped the automatic sign-in category, because it matched only the raw "(automatic sign-in failed: X)" form. `download --assignment --json` formats twice, so a `code_rejected` failure became a plain "Session expired". The pattern now also matches the formatter's own form; the category is still checked against the known set. The secrets baseline refresh only moves line numbers in tests/test_cli_contracts.py. --- .secrets.baseline | 10 +++++----- lighthouse_cli/display.py | 4 +++- tests/test_cli_contracts.py | 7 +++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 7a0443a..5b6a238 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -247,7 +247,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "467a24615d843d998a1579ca537fb498a1880a23", "is_verified": false, - "line_number": 649, + "line_number": 656, "is_secret": false }, { @@ -255,7 +255,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "2dc5053699a351121bf839c446bd4a878dda5735", "is_verified": false, - "line_number": 664, + "line_number": 671, "is_secret": false }, { @@ -263,7 +263,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "44ae3aa5a71fbc4b2b1bc30a17ba4318cd7feb9b", "is_verified": false, - "line_number": 1379, + "line_number": 1386, "is_secret": false }, { @@ -271,7 +271,7 @@ "filename": "tests/test_cli_contracts.py", "hashed_secret": "09aaa0ab2b1665c54cf74694200328fb65652628", "is_verified": false, - "line_number": 1380, + "line_number": 1387, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-14T15:50:58Z" + "generated_at": "2026-10-15T02:55:30Z" } diff --git a/lighthouse_cli/display.py b/lighthouse_cli/display.py index e6800f2..8b38b82 100644 --- a/lighthouse_cli/display.py +++ b/lighthouse_cli/display.py @@ -494,7 +494,9 @@ def _safe_local_message(raw: str) -> str | None: "credentials_rejected", "code_rejected", "method_unavailable", "clock_skew", "interrupt", "network", "store", "busy", "pending_human_login", "unknown", }) -_RENEWAL_FAILURE_RE = re.compile(r"\(automatic sign-in failed: ([a-z_]{1,32})\)") +# The raw "(automatic sign-in failed: X)" or this formatter's own +# "automatic sign-in failed (X)", so formatting twice keeps the category. +_RENEWAL_FAILURE_RE = re.compile(r"automatic sign-in failed(?:: | \()([a-z_]{1,32})\)") _STATUS_CATEGORIES = { 401: "Session expired", 403: "Permission denied", 404: "Not found", 429: "Rate limited", diff --git a/tests/test_cli_contracts.py b/tests/test_cli_contracts.py index 65db60f..62de55a 100644 --- a/tests/test_cli_contracts.py +++ b/tests/test_cli_contracts.py @@ -606,6 +606,13 @@ def test_format_user_error_names_the_automatic_sign_in_failure(category: str) -> ) +@pytest.mark.parametrize("category", sorted(RELOGIN_CATEGORIES)) +def test_formatting_a_sign_in_failure_twice_keeps_its_category(category: str) -> None: + once = format_user_error(_session_expired(f"automatic sign-in failed: {category}")) + + assert format_user_error(once) == once + + @pytest.mark.parametrize( "raw", [ From 0ee2e165b9c3e9a7f97a24b6c9e5247616a70e8a Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:41:25 +0530 Subject: [PATCH 12/12] fix(api): only the process that signs in says it is signing in Live test L4 on MAHE: of two concurrent reads with an expired session, the one that waited on the sign-in lock and reused the other's cookies also printed "Signing in again with the stored authenticator...". renew_session takes an on_sign_in callback and calls it once, right before it contacts Microsoft: after the cookie reuse check, the breaker and cooldown gates, the username match and the in-flight breaker write. api.py prints the notice from that callback, so a process that reuses cookies, is refused by a gate or has no usable authenticator stays quiet. A failing notice (closed stderr) is ignored instead of being recorded as a failed sign-in. Plan review (gpt-6.1-sol): added the breaker-write and interrupted- notice boundary tests and a real blocking category for the api test. --- .secrets.baseline | 4 +- lighthouse_cli/api.py | 9 ++- lighthouse_cli/session_renewal.py | 9 +++ tests/_relogin_mp_helper.py | 5 ++ tests/test_api_transport.py | 26 ++++++- tests/test_session_renewal.py | 91 +++++++++++++++++++++- tests/test_session_renewal_multiprocess.py | 5 +- 7 files changed, 140 insertions(+), 9 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 5b6a238..b70ef88 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -169,7 +169,7 @@ "filename": "tests/test_api_transport.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 750, + "line_number": 751, "is_secret": false } ], @@ -392,5 +392,5 @@ } ] }, - "generated_at": "2026-10-15T02:55:30Z" + "generated_at": "2026-10-15T06:11:07Z" } diff --git a/lighthouse_cli/api.py b/lighthouse_cli/api.py index db25841..1661f20 100644 --- a/lighthouse_cli/api.py +++ b/lighthouse_cli/api.py @@ -314,6 +314,11 @@ def _session_expired(detail: str) -> SessionExpiredError: ) +def _announce_sign_in() -> None: + """Tell the user this process is signing in; a waiter that reuses cookies stays quiet.""" + print("Session expired. Signing in again with the stored authenticator...", file=sys.stderr) + + def _is_login_redirect(location: object) -> bool: """Recognize supported login endpoints without substring false positives.""" try: @@ -570,10 +575,10 @@ def _whoami_signed_out(self) -> bool: def _recover_session(self, *, browser_fallback: bool) -> None: """Replace expired cookies: stored authenticator first, then CDP.""" if unattended_configured(): - print("Session expired. Signing in again with the stored authenticator...", file=sys.stderr) try: result = renew_session( - observed_cookies=dict(self._cookies), validate=probe_cookies, automatic=True + observed_cookies=dict(self._cookies), validate=probe_cookies, automatic=True, + on_sign_in=_announce_sign_in, ) except RenewalUnavailableError: pass diff --git a/lighthouse_cli/session_renewal.py b/lighthouse_cli/session_renewal.py index 4b9558d..c957e05 100644 --- a/lighthouse_cli/session_renewal.py +++ b/lighthouse_cli/session_renewal.py @@ -524,6 +524,7 @@ def _attempt( credentials: tuple[str, str] | None, persist_credentials: bool, state: _BreakerState, + on_sign_in: Callable[[], None] | None, ) -> RenewalResult: if automatic: reused = _reuse_existing(observed, validate) @@ -543,6 +544,10 @@ def _attempt( # nor a process killed mid-sign-in lets every read send the password. now = _utc_now().isoformat() _write_state(store, _BreakerState(True, "unknown", now, now)) + if on_sign_in is not None: + # A notice only; a closed stderr must not count as a failed sign-in. + with suppress(Exception): + on_sign_in() cookies = _sign_in(store, username, password, stored_totp) save_cookies(cookies) probe = validate(dict(cookies)) @@ -562,6 +567,7 @@ def renew_session( automatic: bool, credentials: tuple[str, str] | None = None, persist_credentials: bool = False, + on_sign_in: Callable[[], None] | None = None, ) -> RenewalResult: """Sign in with the stored authenticator under the sign-in lock. @@ -574,6 +580,8 @@ def renew_session( credentials: The username/password ``auth login`` resolved; ``None`` loads the saved pair. persist_credentials: Save ``credentials`` after a validated sign-in. + on_sign_in: Called once just before this call signs in; not called + when it reuses cookies, a gate refuses, or nothing is attempted. Raises: RenewalUnavailableError: No stored authenticator matches the credentials, @@ -595,6 +603,7 @@ def renew_session( credentials=credentials, persist_credentials=persist_credentials, state=state, + on_sign_in=on_sign_in, ) except RenewalUnavailableError: raise diff --git a/tests/_relogin_mp_helper.py b/tests/_relogin_mp_helper.py index 00a7c92..e2c9663 100644 --- a/tests/_relogin_mp_helper.py +++ b/tests/_relogin_mp_helper.py @@ -91,11 +91,16 @@ def validate(cookies: dict[str, str]) -> sr.SessionProbe: return "valid" return "unavailable" + def announce() -> None: + with Path(counter_file).open("a", encoding="utf-8") as counter: + counter.write("notice\n") + try: result = sr.renew_session( observed_cookies=observed_cookies, validate=validate, automatic=True, + on_sign_in=announce, ) except RenewalError as exc: outcome = f"blocked:{exc.category}" if failure_category is None else f"error:{exc.category}" diff --git a/tests/test_api_transport.py b/tests/test_api_transport.py index ba218e4..b9bcf42 100644 --- a/tests/test_api_transport.py +++ b/tests/test_api_transport.py @@ -10,6 +10,7 @@ import urllib.request from collections.abc import Iterator from contextlib import contextmanager +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -1180,8 +1181,17 @@ def _jar(session: JarSession) -> dict[str, str]: @pytest.fixture def renewal(monkeypatch: pytest.MonkeyPatch) -> MagicMock: """Unattended sign-in is configured and signs in with fresh cookies.""" + + def sign_in(**kwargs: Any) -> RenewalResult: + result: RenewalResult = renew.return_value + if result.signed_in: + kwargs["on_sign_in"]() + return result + monkeypatch.setattr(api, "unattended_configured", lambda: True) - renew = MagicMock(return_value=RenewalResult(dict(RENEWED_COOKIES), signed_in=True)) + renew = MagicMock( + return_value=RenewalResult(dict(RENEWED_COOKIES), signed_in=True), side_effect=sign_in, + ) monkeypatch.setattr(api, "renew_session", renew) return renew @@ -1383,6 +1393,7 @@ def test_a_403_from_a_signed_out_session_signs_in_once_and_replays_once( assert session.urls == [RESOURCE_URL, WHOAMI_URL, RESOURCE_URL] renewal.assert_called_once_with( observed_cookies=SYNTHETIC_COOKIES, validate=api.probe_cookies, automatic=True, + on_sign_in=api._announce_sign_in, ) browser.assert_not_called() assert client.renewed_session is True @@ -1520,7 +1531,9 @@ def test_a_403_after_signing_in_for_a_401_does_not_sign_in_twice( def test_a_failed_sign_in_after_a_signed_out_403_records_its_category( renewal: MagicMock, browser: MagicMock, + capsys: pytest.CaptureFixture[str], ) -> None: + # A refusal by the breaker: renew_session raises without signing in. renewal.side_effect = RenewalError("code_rejected") client, session = _jar_client([FakeResponse(403), FakeResponse(403)]) @@ -1530,6 +1543,7 @@ def test_a_failed_sign_in_after_a_signed_out_403_records_its_category( assert client.renewal_failure == "code_rejected" assert session.urls == [RESOURCE_URL, WHOAMI_URL] browser.assert_not_called() + assert "Signing in again" not in capsys.readouterr().err def test_a_signed_out_403_on_a_later_page_resumes_that_page_once(renewal: MagicMock) -> None: @@ -1613,6 +1627,7 @@ def test_an_expired_session_signs_in_once_and_replays_once( assert session.calls == [method, method] renewal.assert_called_once_with( observed_cookies=SYNTHETIC_COOKIES, validate=api.probe_cookies, automatic=True, + on_sign_in=api._announce_sign_in, ) browser.assert_not_called() assert client.renewed_session is True @@ -1626,7 +1641,10 @@ def test_an_expired_session_signs_in_once_and_replays_once( assert "renewed-sentinel" not in captured.err -def test_reusing_another_callers_sign_in_is_not_reported_as_renewed(renewal: MagicMock) -> None: +def test_reusing_another_callers_sign_in_is_not_reported_as_renewed( + renewal: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: renewal.return_value = RenewalResult(dict(RENEWED_COOKIES), signed_in=False) client, _session = _jar_client([FakeResponse(401), FakeResponse(200)]) @@ -1634,6 +1652,7 @@ def test_reusing_another_callers_sign_in_is_not_reported_as_renewed(renewal: Mag assert client.renewed_session is False assert client.cookies == RENEWED_COOKIES + assert "Signing in again" not in capsys.readouterr().err @pytest.mark.parametrize("status", [401, 403]) @@ -1684,6 +1703,7 @@ def test_missing_cookies_sign_in_before_the_request_when_configured( renewal.assert_called_once_with( observed_cookies=stored or {}, validate=api.probe_cookies, automatic=True, + on_sign_in=api._announce_sign_in, ) browser.assert_not_called() assert session.calls == ["GET"] @@ -1706,6 +1726,7 @@ def test_missing_cookies_without_unattended_sign_in_fail_before_any_request( def test_missing_cookies_and_no_matching_authenticator_never_open_the_browser( renewal: MagicMock, browser: MagicMock, + capsys: pytest.CaptureFixture[str], ) -> None: renewal.side_effect = RenewalUnavailableError() client, session = _jar_client([], cookies=None) @@ -1716,6 +1737,7 @@ def test_missing_cookies_and_no_matching_authenticator_never_open_the_browser( assert session.calls == [] renewal.assert_called_once() browser.assert_not_called() + assert "Signing in again" not in capsys.readouterr().err def test_expiry_with_no_matching_authenticator_falls_back_to_the_browser( diff --git a/tests/test_session_renewal.py b/tests/test_session_renewal.py index 3191aef..34c4b21 100644 --- a/tests/test_session_renewal.py +++ b/tests/test_session_renewal.py @@ -65,6 +65,7 @@ class FakeSSO: def __init__(self) -> None: self.logins: list[dict[str, Any]] = [] self.codes: list[str] = [] + self.events: list[str] = [] self.closed = 0 self.result: Callable[[], dict[str, str]] = lambda: _cookies("fresh") self.delay = 0.0 @@ -73,6 +74,7 @@ def factory(self) -> FakeSSO: return self def login(self, username: str, password: str, **kwargs: Any) -> dict[str, str]: + self.events.append("login") self.logins.append({"username": username, "password": password, **kwargs}) self.codes.append(kwargs["totp_provider"]()) if self.delay: @@ -236,6 +238,91 @@ def test_invalid_newer_cookies_fall_through_to_sign_in(configured: CredentialSto assert len(sso.logins) == 1 +# --------------------------------------------------------------------------- +# Sign-in notice +# --------------------------------------------------------------------------- + + +def test_sign_in_notice_comes_once_before_the_login(configured: CredentialStore, sso: FakeSSO) -> None: + result = _renew(on_sign_in=lambda: sso.events.append("notice")) + assert result.signed_in is True + assert sso.events == ["notice", "login"] + + +def test_reusing_cookies_gives_no_sign_in_notice(configured: CredentialStore, sso: FakeSSO) -> None: + save_cookies(_cookies("other-caller")) + notices: list[str] = [] + result = _renew(observed_cookies=_cookies("stale"), on_sign_in=lambda: notices.append("notice")) + assert result.signed_in is False + assert notices == [] + assert sso.logins == [] + + +@pytest.mark.parametrize( + ("gate", "error"), + [ + ("code_rejected", MicrosoftSSOError("x", category="code_rejected")), + ("network", requests.ConnectionError("down")), + ("pending_human_login", None), + ], +) +def test_a_refused_sign_in_gives_no_notice( + configured: CredentialStore, config_dir: Path, sso: FakeSSO, gate: str, error: Exception | None +) -> None: + if error is None: + (config_dir / "mfa_pending.json").write_text("{}") + else: + _fail_with(sso, error) + with pytest.raises(RenewalError): + _renew() + logins = len(sso.logins) + notices: list[str] = [] + with pytest.raises(RenewalError) as caught: + _renew(on_sign_in=lambda: notices.append("notice")) + assert caught.value.category == gate + assert notices == [] + assert len(sso.logins) == logins + + +def test_no_usable_pair_gives_no_sign_in_notice(configured: CredentialStore, sso: FakeSSO) -> None: + notices: list[str] = [] + with pytest.raises(RenewalUnavailableError): + _renew( + automatic=False, + credentials=("other@example.edu", PASSWORD), + on_sign_in=lambda: notices.append("notice"), + ) + assert notices == [] + assert sso.logins == [] + + +def test_a_failing_notice_does_not_fail_the_sign_in( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + def broken_stderr() -> None: + raise ValueError("I/O operation on closed file.") + + result = _renew(on_sign_in=broken_stderr) + assert result.signed_in is True + assert len(sso.logins) == 1 + assert _state(config_dir) is None + + +def test_an_interrupted_notice_blocks_without_signing_in( + configured: CredentialStore, config_dir: Path, sso: FakeSSO +) -> None: + def interrupt() -> None: + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + _renew(on_sign_in=interrupt) + assert sso.logins == [] + state = _state(config_dir) + assert state is not None and (state["blocked"], state["category"]) == (True, "unknown") + with renewal_lock(timeout=0): + pass + + def test_unavailable_probe_on_reuse_records_network_cooldown( configured: CredentialStore, config_dir: Path, sso: FakeSSO ) -> None: @@ -410,11 +497,13 @@ def fail_write(*_args: object, **_kwargs: object) -> None: raise error monkeypatch.setattr(sr, "atomic_write", fail_write) + notices: list[str] = [] for _ in range(2): with pytest.raises(RenewalError) as caught: - _renew() + _renew(on_sign_in=lambda: notices.append("notice")) assert caught.value.category == "store" assert sso.logins == [] + assert notices == [] assert _state(config_dir) is None diff --git a/tests/test_session_renewal_multiprocess.py b/tests/test_session_renewal_multiprocess.py index 0603993..3a86b98 100644 --- a/tests/test_session_renewal_multiprocess.py +++ b/tests/test_session_renewal_multiprocess.py @@ -145,7 +145,8 @@ def test_two_processes_share_one_unattended_sign_in( assert sorted(outcome for outcome, _ in results.values()) == ["reused", "signed_in"] assert sum(provider_calls for _, provider_calls in results.values()) == 1 - assert counter_file.read_text(encoding="utf-8").splitlines() == ["login"] + # Only the process that signs in prints the notice; the other reuses its cookies. + assert counter_file.read_text(encoding="utf-8").splitlines() == ["notice", "login"] assert load_cookies(read_only=True) == FRESH_COOKIES @@ -174,7 +175,7 @@ def test_blocked_breaker_prevents_second_process_and_totp_step_reuse( start_together=False, ) assert second == {"blocked-worker": ("blocked:code_rejected", 0)} - assert counter_file.read_text(encoding="utf-8").splitlines() == ["login"] + assert counter_file.read_text(encoding="utf-8").splitlines() == ["notice", "login"] state = json.loads((config_dir / "relogin_state.json").read_text(encoding="utf-8")) assert state["blocked"] is True