Metadata-Version: 2.4
Name: duo-pass
Version: 0.0.2
Summary: Duo second-factor handling for Playwright logins: push approval and passcodes behind one interface
Author: Jei Blanchard
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/doctorjei/duo-pass
Project-URL: Source, https://github.com/doctorjei/duo-pass
Project-URL: Issues, https://github.com/doctorjei/duo-pass/issues
Keywords: duo,mfa,playwright,authentication
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: playwright>=1.40
Dynamic: license-file

# duo-pass

Duo second-factor handling for Playwright logins — push approval with Verified
Push number matching, or a typed passcode — behind one `Approver` interface.
Spun out of [canvasser](https://github.com/doctorjei/canvasser), where it was
proved against a live Duo tenant. GPL-3.0-or-later.

This module never asks how the Duo page was reached and never leaves the Duo
host except by Duo handing control back. The login flow that got here is the
host application's business.

```python
from playwright.sync_api import sync_playwright
from duo_pass import complete_duo, PushApprover, configure

configure(
    snapshot=lambda page, label: save_somewhere(page, label),
    announce=lambda number: show_somehow(number),
)

with sync_playwright() as p:
    ...  # reach the Duo challenge on `page`
    complete_duo(page, PushApprover())  # or PasscodeApprover()
```

## Hooks

A library must not invent paths or terminal chrome, so three seams connect
this module to its host. All have working defaults; all should be overridden
by any host with its own state directory, display layer, or code source.

| seam | signature | default |
|---|---|---|
| `snapshot` | `(page, label) -> Path` | PNG under `./snapshots/`, mode 700/600 |
| `announce` | `(number: str \| None) -> None` | framed box on stderr |
| `code_source` | `() -> str` | prompt on the controlling terminal |

`snapshot` fires as `duo-exit-error` (Duo refused the session) and
`duo-timeout` (still on Duo when the wait expired). `announce` fires with
Duo's Verified Push number, or `None` when the tenant has Verified Push off —
absence is normal, not an error, and the frame is drawn either way.
`code_source` is taken by `PasscodeApprover(code_source=...)` and supplies the
6-digit code; the default prompts on `/dev/tty` — never stdin, so a pipe or a
redirect cannot silently feed it — and refuses with guidance where no terminal
exists. A host with a secrets file, a vault, a web UI, or no terminal supplies
its own instead of subclassing.

## Notes

- The wait watches for *leaving the Duo host*, never for Duo markup — markup
  changes, leaving does not.
- The trust prompt ("remember this device") is clicked *during* the wait, not
  after it: waiting to leave Duo before handling the prompt deadlocks, because
  the missing click is what would let us leave.
- The Verified Push number is re-read every few seconds while waiting. A push
  can expire and be re-sent with a different number, and a stale number on
  screen is worse than none.
- A Duo `error=` exit is reported as Duo refusing the browser, not as the user
  failing to approve. A timeout says what it knows: approval may have worked
  while Duo never handed control back.
- Nothing here retries a credential. Repeatedly submitting a bad passcode can
  lock the account; a wrong code raises `ApprovalError` instead.
