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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
55e806ba7338095a15f052f6a78a6700404d368b:tests/test_auth_totp.py:generic-api-key:32
38 changes: 19 additions & 19 deletions .secrets.baseline

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

17 changes: 13 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
63 changes: 54 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,18 @@ graph TD
with `--mfa-method app --totp <code>`. `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.
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:**
```
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: category is documented as conditional but is always present

The implementation always includes category in failure documents, with null when no automatic sign-in was attempted (_status_payload; tests assert "category": None). A consumer written strictly from this wording could mishandle the always-present null. Suggested wording: "and a category (null when no automatic sign-in was attempted)".


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

`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]`
Expand All @@ -335,6 +351,12 @@ commands. Non-interactive runs use `auth verify <code|ok>` 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
Expand All @@ -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:**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/adr/0002-headless-browser-auth-with-flexible-2fa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 17 additions & 0 deletions docs/auth-microsoft-sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading