docauto-mcp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ # Kubernetes secrets e valores Helm com credenciais — NUNCA commitar
2
+ k8s/secrets.yaml
3
+ k8s/keycloak/secrets.yaml
4
+ k8s/prometheus-values.yaml
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ .venv/
11
+ dist/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .pytest_cache/
15
+ htmlcov/
16
+ .coverage
17
+
18
+ # Env
19
+ .env
20
+ .env.local
21
+
22
+ # Node
23
+ node_modules/
24
+ .next/
25
+ out/
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Docker volumes
32
+ postgres_data/
33
+ minio_data/
34
+
35
+ # Use-case test outputs (keep script, template, csv and results.md)
36
+ use-case-tests/output/
37
+
38
+ # References mvp_docauto.md and guide_claude_code_docauto.pdf
39
+ references/
40
+
41
+ #Visual Studio Code
42
+ .vscode/
43
+
44
+ #Claude Settings
45
+ .claude/settings.local.json
46
+
47
+ # AI agent local tooling configs (not project instructions)
48
+ .playwright-mcp/
49
+ .mcp.json
50
+
51
+ # Claude Code skill run artifacts (ephemeral results — not project state)
52
+ .claude/skills/**/*_RESULTS.md
53
+ .claude/skills/**/RESULTS/
54
+
55
+ # Frontend test artifacts
56
+ frontend/test-results/
57
+ frontend/playwright-report/
58
+
59
+ # Git worktrees
60
+ .worktrees/
61
+
62
+ # Secrets / environment mapping — never commit
63
+ ENVIRONMENTS.md
64
+
65
+ # Local planning and roadmap notes — never commit
66
+ FEATURES.md
@@ -0,0 +1,24 @@
1
+ # Hosted DocAuto MCP server (Streamable HTTP). Built for linux/arm64 (Oracle ARM).
2
+ # Build context is this directory (mcp/).
3
+ FROM python:3.13-slim
4
+
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1
7
+
8
+ WORKDIR /app
9
+
10
+ # Install the package (and its deps: mcp, httpx, uvicorn) from its own metadata.
11
+ COPY pyproject.toml README.md ./
12
+ COPY docauto_mcp/ docauto_mcp/
13
+ RUN pip install --no-cache-dir .
14
+
15
+ RUN adduser --disabled-password --gecos "" appuser
16
+ USER appuser
17
+
18
+ EXPOSE 8000
19
+
20
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
21
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" || exit 1
22
+
23
+ CMD ["uvicorn", "docauto_mcp.server_http:http_app", "--factory", \
24
+ "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: docauto-mcp
3
+ Version: 0.2.0
4
+ Summary: Model Context Protocol server for the DocAuto document-generation API.
5
+ Project-URL: Homepage, https://docauto.com.br
6
+ Project-URL: Documentation, https://docauto.com.br/docs/mcp
7
+ Project-URL: Repository, https://github.com/gzucob/docauto
8
+ Author: DocAuto
9
+ License-Expression: MIT
10
+ Keywords: ai,docauto,documents,mcp,model-context-protocol
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Office/Business
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: mcp<2,>=1.12
23
+ Requires-Dist: uvicorn>=0.30
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # DocAuto MCP server
30
+
31
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
32
+ the DocAuto document-generation workflow to AI assistants. It is a thin client
33
+ over the public `/api/v1` surface — it holds no secrets and enforces no
34
+ business logic; multi-tenancy and validation live in the backend.
35
+
36
+ - **Transport:** stdio (local) and a hosted Streamable-HTTP server at
37
+ `https://mcp.docauto.com.br/mcp` (add it as a remote connector — no install).
38
+ - **Auth (local):** OAuth 2.0 Device Authorization Grant against the Keycloak
39
+ `docauto-cli` public client. You sign in once in your browser; tokens are
40
+ cached at `~/.docauto/mcp-tokens.json` (0600) and refreshed automatically.
41
+
42
+ ## Install
43
+
44
+ The server is a standalone Python package (it is **not** part of the backend).
45
+ Requires Python ≥ 3.11.
46
+
47
+ ```bash
48
+ # run on demand, no install:
49
+ uvx docauto-mcp
50
+ # or install it:
51
+ pipx install docauto-mcp
52
+ ```
53
+
54
+ From a checkout (dev): `pip install ./mcp`.
55
+
56
+ ## Configure your MCP client
57
+
58
+ ### Claude Desktop — automatic
59
+
60
+ The package ships a setup helper that writes `claude_desktop_config.json` for
61
+ you (handling the Windows Store/MSIX config-path quirk):
62
+
63
+ ```bash
64
+ docauto-mcp-install # register the installed `docauto-mcp` command
65
+ docauto-mcp-install --command uvx # zero-install: run via `uvx docauto-mcp`
66
+ docauto-mcp-install --print # print the JSON block instead of writing it
67
+ ```
68
+
69
+ ### Claude Desktop — manual
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "docauto": {
75
+ "command": "docauto-mcp"
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ If `docauto-mcp` is not on your PATH, use the `uvx` form
82
+ (`"command": "uvx", "args": ["docauto-mcp"]`) or point `command` at the script
83
+ inside your virtualenv.
84
+
85
+ ### Claude Code (CLI)
86
+
87
+ ```bash
88
+ claude mcp add docauto -- uvx docauto-mcp
89
+ ```
90
+
91
+ ### Cursor / VS Code / other clients
92
+
93
+ These accept a remote MCP server by URL — see the **hosted** server below
94
+ (`https://mcp.docauto.com.br/mcp`), which needs no local install.
95
+
96
+ ### Environment overrides (optional)
97
+
98
+ Defaults target production; override only for local dev:
99
+
100
+ | Variable | Default |
101
+ |---|---|
102
+ | `DOCAUTO_MCP_ISSUER` | `https://auth.docauto.com.br/realms/docauto` |
103
+ | `DOCAUTO_MCP_API_BASE` | `https://api.docauto.com.br` (server-to-server; set to the internal service in-cluster) |
104
+ | `DOCAUTO_MCP_PUBLIC_API_BASE` | `https://api.docauto.com.br` (public base for user-facing links, e.g. signed download URLs) |
105
+ | `DOCAUTO_MCP_PUBLIC_APP_BASE` | `https://docauto.com.br` (public frontend base for the upload page link) |
106
+ | `DOCAUTO_MCP_CLIENT_ID` | `docauto-cli` |
107
+ | `DOCAUTO_MCP_HOME` | `~/.docauto` (token cache location) |
108
+
109
+ ## Logging in
110
+
111
+ You sign in with your normal DocAuto account (the MCP server does not create a
112
+ different kind of account):
113
+
114
+ 1. Call **`login_start`** — it returns a verification URL and a short code.
115
+ 2. Open the URL, sign in (email/password or Google), and approve.
116
+ 3. Call **`login_finish`** — it completes the login and caches your tokens.
117
+
118
+ After that the assistant can use the tools below; tokens refresh silently until
119
+ the session expires.
120
+
121
+ ## Prompts
122
+
123
+ - **`generate_documents`** (argument: `output_format` = `pdf` | `docx`) — a
124
+ guided template that drives the whole batch-generation flow. Available in any
125
+ client that surfaces MCP prompts, over both transports.
126
+
127
+ ## Tools
128
+
129
+ - **Session:** `login_start`, `login_finish`, `logout`, `whoami`, `doctor`,
130
+ `workflow_guide` — `doctor` reports the version, the configured endpoints, API
131
+ reachability, and sign-in state (no secrets)
132
+ - **Templates:** `list_templates`, `get_template`, `upload_template`, `delete_template`
133
+ - **Datasets:** `list_datasets`, `get_dataset`, `preview_dataset`, `upload_dataset`, `delete_dataset`
134
+ - **Generation:** `validate_mapping`, `create_generation_job`, `get_job`, `list_jobs`, `cancel_job`, `delete_job`, `list_job_documents`
135
+ - **Downloads:** `download_document`, `download_job_zip` — over stdio these save
136
+ to the user's local Downloads folder; over the hosted HTTP transport
137
+ `download_job_zip` returns a short-lived signed URL the user opens in a browser
138
+ (the ZIP is never streamed through the model, so there is no size cap)
139
+ - **Uploads (hosted HTTP only):** over stdio `upload_template`/`upload_dataset`
140
+ read a local path; over the hosted transport they take only a `filename` and
141
+ return an `upload_url` the user opens to send the file (the bytes never pass
142
+ through the model), plus an `upload_ref` to poll with `check_upload` until the
143
+ result is `ready`
144
+
145
+ ## Typical flow
146
+
147
+ `login_start` → `login_finish` → `whoami` → `upload_template(path)` →
148
+ `upload_dataset(path)` → `validate_mapping(...)` →
149
+ `create_generation_job(...)` → poll `get_job(job_id)` →
150
+ `download_job_zip(job_id, dest)`.
151
+
152
+ The `field_mapping` maps each template variable to a dataset column, e.g.
153
+ `{"nome_cliente": "nome_cliente", "cpf": "cpf"}`. `output_format` is `pdf`
154
+ (default) or `docx`.
155
+
156
+ ## Development
157
+
158
+ ```bash
159
+ # unit tests (no MCP SDK needed — auth/client/tools are isolated)
160
+ python -m pytest tests
161
+ ```
@@ -0,0 +1,133 @@
1
+ # DocAuto MCP server
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
4
+ the DocAuto document-generation workflow to AI assistants. It is a thin client
5
+ over the public `/api/v1` surface — it holds no secrets and enforces no
6
+ business logic; multi-tenancy and validation live in the backend.
7
+
8
+ - **Transport:** stdio (local) and a hosted Streamable-HTTP server at
9
+ `https://mcp.docauto.com.br/mcp` (add it as a remote connector — no install).
10
+ - **Auth (local):** OAuth 2.0 Device Authorization Grant against the Keycloak
11
+ `docauto-cli` public client. You sign in once in your browser; tokens are
12
+ cached at `~/.docauto/mcp-tokens.json` (0600) and refreshed automatically.
13
+
14
+ ## Install
15
+
16
+ The server is a standalone Python package (it is **not** part of the backend).
17
+ Requires Python ≥ 3.11.
18
+
19
+ ```bash
20
+ # run on demand, no install:
21
+ uvx docauto-mcp
22
+ # or install it:
23
+ pipx install docauto-mcp
24
+ ```
25
+
26
+ From a checkout (dev): `pip install ./mcp`.
27
+
28
+ ## Configure your MCP client
29
+
30
+ ### Claude Desktop — automatic
31
+
32
+ The package ships a setup helper that writes `claude_desktop_config.json` for
33
+ you (handling the Windows Store/MSIX config-path quirk):
34
+
35
+ ```bash
36
+ docauto-mcp-install # register the installed `docauto-mcp` command
37
+ docauto-mcp-install --command uvx # zero-install: run via `uvx docauto-mcp`
38
+ docauto-mcp-install --print # print the JSON block instead of writing it
39
+ ```
40
+
41
+ ### Claude Desktop — manual
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "docauto": {
47
+ "command": "docauto-mcp"
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ If `docauto-mcp` is not on your PATH, use the `uvx` form
54
+ (`"command": "uvx", "args": ["docauto-mcp"]`) or point `command` at the script
55
+ inside your virtualenv.
56
+
57
+ ### Claude Code (CLI)
58
+
59
+ ```bash
60
+ claude mcp add docauto -- uvx docauto-mcp
61
+ ```
62
+
63
+ ### Cursor / VS Code / other clients
64
+
65
+ These accept a remote MCP server by URL — see the **hosted** server below
66
+ (`https://mcp.docauto.com.br/mcp`), which needs no local install.
67
+
68
+ ### Environment overrides (optional)
69
+
70
+ Defaults target production; override only for local dev:
71
+
72
+ | Variable | Default |
73
+ |---|---|
74
+ | `DOCAUTO_MCP_ISSUER` | `https://auth.docauto.com.br/realms/docauto` |
75
+ | `DOCAUTO_MCP_API_BASE` | `https://api.docauto.com.br` (server-to-server; set to the internal service in-cluster) |
76
+ | `DOCAUTO_MCP_PUBLIC_API_BASE` | `https://api.docauto.com.br` (public base for user-facing links, e.g. signed download URLs) |
77
+ | `DOCAUTO_MCP_PUBLIC_APP_BASE` | `https://docauto.com.br` (public frontend base for the upload page link) |
78
+ | `DOCAUTO_MCP_CLIENT_ID` | `docauto-cli` |
79
+ | `DOCAUTO_MCP_HOME` | `~/.docauto` (token cache location) |
80
+
81
+ ## Logging in
82
+
83
+ You sign in with your normal DocAuto account (the MCP server does not create a
84
+ different kind of account):
85
+
86
+ 1. Call **`login_start`** — it returns a verification URL and a short code.
87
+ 2. Open the URL, sign in (email/password or Google), and approve.
88
+ 3. Call **`login_finish`** — it completes the login and caches your tokens.
89
+
90
+ After that the assistant can use the tools below; tokens refresh silently until
91
+ the session expires.
92
+
93
+ ## Prompts
94
+
95
+ - **`generate_documents`** (argument: `output_format` = `pdf` | `docx`) — a
96
+ guided template that drives the whole batch-generation flow. Available in any
97
+ client that surfaces MCP prompts, over both transports.
98
+
99
+ ## Tools
100
+
101
+ - **Session:** `login_start`, `login_finish`, `logout`, `whoami`, `doctor`,
102
+ `workflow_guide` — `doctor` reports the version, the configured endpoints, API
103
+ reachability, and sign-in state (no secrets)
104
+ - **Templates:** `list_templates`, `get_template`, `upload_template`, `delete_template`
105
+ - **Datasets:** `list_datasets`, `get_dataset`, `preview_dataset`, `upload_dataset`, `delete_dataset`
106
+ - **Generation:** `validate_mapping`, `create_generation_job`, `get_job`, `list_jobs`, `cancel_job`, `delete_job`, `list_job_documents`
107
+ - **Downloads:** `download_document`, `download_job_zip` — over stdio these save
108
+ to the user's local Downloads folder; over the hosted HTTP transport
109
+ `download_job_zip` returns a short-lived signed URL the user opens in a browser
110
+ (the ZIP is never streamed through the model, so there is no size cap)
111
+ - **Uploads (hosted HTTP only):** over stdio `upload_template`/`upload_dataset`
112
+ read a local path; over the hosted transport they take only a `filename` and
113
+ return an `upload_url` the user opens to send the file (the bytes never pass
114
+ through the model), plus an `upload_ref` to poll with `check_upload` until the
115
+ result is `ready`
116
+
117
+ ## Typical flow
118
+
119
+ `login_start` → `login_finish` → `whoami` → `upload_template(path)` →
120
+ `upload_dataset(path)` → `validate_mapping(...)` →
121
+ `create_generation_job(...)` → poll `get_job(job_id)` →
122
+ `download_job_zip(job_id, dest)`.
123
+
124
+ The `field_mapping` maps each template variable to a dataset column, e.g.
125
+ `{"nome_cliente": "nome_cliente", "cpf": "cpf"}`. `output_format` is `pdf`
126
+ (default) or `docx`.
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ # unit tests (no MCP SDK needed — auth/client/tools are isolated)
132
+ python -m pytest tests
133
+ ```
@@ -0,0 +1,10 @@
1
+ """DocAuto MCP server — a thin Model Context Protocol server over the DocAuto API.
2
+
3
+ The package is a standalone deployable (not part of the FastAPI monolith): it
4
+ talks to the public ``/api/v1`` surface over HTTP and authenticates as the user
5
+ via the Keycloak ``docauto-cli`` public client (OAuth 2.0 Device Authorization
6
+ Grant). Multi-tenancy is enforced server-side, so the MCP server holds no
7
+ secrets and never sees another tenant's data.
8
+ """
9
+
10
+ __version__ = "0.2.0"
@@ -0,0 +1,288 @@
1
+ """OAuth 2.0 Device Authorization Grant for the DocAuto MCP server.
2
+
3
+ The MCP server authenticates the user as the Keycloak ``docauto-cli`` public
4
+ client using the device flow (RFC 8628): no redirect URI, no local web server —
5
+ the user opens a verification URL, approves, and the server polls for the token.
6
+ Tokens are cached on disk and silently refreshed; only the first login (or an
7
+ expired refresh token) requires the interactive step.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import secrets
17
+ import stat
18
+ import time
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import httpx
25
+
26
+ from .config import Settings
27
+ from .errors import AuthError, NotAuthenticatedError
28
+
29
+ _EXPIRY_SKEW_SECONDS = 30
30
+ _DEFAULT_INTERVAL_SECONDS = 5
31
+ _DEFAULT_DEVICE_TIMEOUT_SECONDS = 600
32
+
33
+
34
+ def _generate_pkce_pair() -> tuple[str, str]:
35
+ """Return an (S256) ``(code_verifier, code_challenge)`` PKCE pair.
36
+
37
+ The ``docauto-cli`` client enforces PKCE, and Keycloak applies it to the
38
+ device flow too: the device authorization request must carry the challenge
39
+ and the token poll must carry the verifier.
40
+ """
41
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
42
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
43
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
44
+ return verifier, challenge
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class TokenSet:
49
+ """A cached set of OAuth tokens."""
50
+
51
+ access_token: str
52
+ refresh_token: str | None
53
+ expires_at: float
54
+
55
+ def is_expired(self, now: float) -> bool:
56
+ """Return whether the access token is expired (with a safety skew)."""
57
+ return now >= self.expires_at - _EXPIRY_SKEW_SECONDS
58
+
59
+
60
+ class TokenStore:
61
+ """Persists a :class:`TokenSet` to a 0600 file on disk."""
62
+
63
+ def __init__(self, path: Path) -> None:
64
+ """Store the on-disk path for the token cache."""
65
+ self._path = path
66
+
67
+ def load(self) -> TokenSet | None:
68
+ """Return the cached token set, or ``None`` if absent or unreadable."""
69
+ try:
70
+ data = json.loads(self._path.read_text(encoding="utf-8"))
71
+ except (OSError, ValueError):
72
+ return None
73
+ try:
74
+ return TokenSet(
75
+ access_token=str(data["access_token"]),
76
+ refresh_token=(
77
+ str(data["refresh_token"]) if data.get("refresh_token") else None
78
+ ),
79
+ expires_at=float(data["expires_at"]),
80
+ )
81
+ except (KeyError, TypeError, ValueError):
82
+ return None
83
+
84
+ def save(self, tokens: TokenSet) -> None:
85
+ """Write the token set to disk with owner-only permissions."""
86
+ self._path.parent.mkdir(parents=True, exist_ok=True)
87
+ self._path.write_text(
88
+ json.dumps(
89
+ {
90
+ "access_token": tokens.access_token,
91
+ "refresh_token": tokens.refresh_token,
92
+ "expires_at": tokens.expires_at,
93
+ }
94
+ ),
95
+ encoding="utf-8",
96
+ )
97
+ try:
98
+ os.chmod(self._path, stat.S_IRUSR | stat.S_IWUSR)
99
+ except OSError:
100
+ # Best-effort on platforms without POSIX permissions (e.g. Windows).
101
+ pass
102
+
103
+ def clear(self) -> None:
104
+ """Delete the cached tokens, if any."""
105
+ try:
106
+ self._path.unlink()
107
+ except FileNotFoundError:
108
+ pass
109
+
110
+
111
+ class DeviceFlowAuthenticator:
112
+ """Obtains and refreshes access tokens via the OAuth device flow."""
113
+
114
+ def __init__(
115
+ self,
116
+ settings: Settings,
117
+ store: TokenStore,
118
+ *,
119
+ http: httpx.Client | None = None,
120
+ sleep: Callable[[float], None] = time.sleep,
121
+ now: Callable[[], float] = time.time,
122
+ ) -> None:
123
+ """Wire the authenticator to its settings, token store, and clock."""
124
+ self._settings = settings
125
+ self._store = store
126
+ self._http = http or httpx.Client(timeout=30.0)
127
+ self._sleep = sleep
128
+ self._now = now
129
+ self._pending: dict[str, Any] | None = None
130
+
131
+ # -- non-interactive ----------------------------------------------------
132
+
133
+ def get_access_token(self) -> str:
134
+ """Return a valid access token without prompting.
135
+
136
+ Uses the cached token, transparently refreshing with the refresh token
137
+ when expired.
138
+
139
+ Raises:
140
+ NotAuthenticatedError: If there is no usable token — the caller
141
+ should ask the user to run the login tool.
142
+ """
143
+ tokens = self._store.load()
144
+ if tokens and not tokens.is_expired(self._now()):
145
+ return tokens.access_token
146
+ if tokens and tokens.refresh_token:
147
+ refreshed = self._refresh(tokens.refresh_token)
148
+ if refreshed is not None:
149
+ self._store.save(refreshed)
150
+ return refreshed.access_token
151
+ raise NotAuthenticatedError(
152
+ "Not authenticated. Run the 'login_start' tool, approve in the "
153
+ "browser, then run 'login_finish'."
154
+ )
155
+
156
+ def is_authenticated(self) -> bool:
157
+ """Return whether a valid (or refreshable) token is available."""
158
+ try:
159
+ self.get_access_token()
160
+ except NotAuthenticatedError:
161
+ return False
162
+ return True
163
+
164
+ def logout(self) -> None:
165
+ """Clear cached tokens and any pending device authorization."""
166
+ self._pending = None
167
+ self._store.clear()
168
+
169
+ # -- interactive device flow -------------------------------------------
170
+
171
+ def start_device_flow(self) -> dict[str, Any]:
172
+ """Begin device authorization and return the user-facing instructions.
173
+
174
+ Returns:
175
+ A mapping with ``verification_uri``, ``verification_uri_complete``
176
+ (when provided), ``user_code``, and ``expires_in``.
177
+ """
178
+ verifier, challenge = _generate_pkce_pair()
179
+ resp = self._http.post(
180
+ self._settings.device_auth_url,
181
+ data={
182
+ "client_id": self._settings.client_id,
183
+ "scope": self._settings.scope,
184
+ "code_challenge": challenge,
185
+ "code_challenge_method": "S256",
186
+ },
187
+ )
188
+ if resp.status_code != 200:
189
+ raise AuthError(
190
+ f"Device authorization request failed ({resp.status_code})."
191
+ )
192
+ data = resp.json()
193
+ interval = int(data.get("interval", _DEFAULT_INTERVAL_SECONDS))
194
+ expires_in = int(data.get("expires_in", _DEFAULT_DEVICE_TIMEOUT_SECONDS))
195
+ self._pending = {
196
+ "device_code": data["device_code"],
197
+ "interval": interval,
198
+ "deadline": self._now() + expires_in,
199
+ "code_verifier": verifier,
200
+ }
201
+ return {
202
+ "verification_uri": data.get("verification_uri"),
203
+ "verification_uri_complete": data.get("verification_uri_complete"),
204
+ "user_code": data.get("user_code"),
205
+ "expires_in": expires_in,
206
+ "message": (
207
+ "Open the verification URL, sign in, and approve. Then run "
208
+ "'login_finish' to complete."
209
+ ),
210
+ }
211
+
212
+ def finish_device_flow(self) -> str:
213
+ """Poll the token endpoint until the user approves (or it times out).
214
+
215
+ Returns:
216
+ A short success message.
217
+
218
+ Raises:
219
+ AuthError: If no device flow is pending, or it is denied/expired.
220
+ """
221
+ if self._pending is None:
222
+ raise AuthError("No login in progress. Run 'login_start' first.")
223
+ device_code = str(self._pending["device_code"])
224
+ interval = int(self._pending["interval"])
225
+ deadline = float(self._pending["deadline"])
226
+ code_verifier = str(self._pending["code_verifier"])
227
+
228
+ while self._now() < deadline:
229
+ self._sleep(interval)
230
+ resp = self._http.post(
231
+ self._settings.token_url,
232
+ data={
233
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
234
+ "client_id": self._settings.client_id,
235
+ "device_code": device_code,
236
+ "code_verifier": code_verifier,
237
+ },
238
+ )
239
+ if resp.status_code == 200:
240
+ self._store.save(self._token_set_from_response(resp.json()))
241
+ self._pending = None
242
+ return "Login successful."
243
+ error = self._error_code(resp)
244
+ if error == "authorization_pending":
245
+ continue
246
+ if error == "slow_down":
247
+ interval += 5
248
+ continue
249
+ self._pending = None
250
+ raise AuthError(f"Device authorization failed: {error}.")
251
+
252
+ self._pending = None
253
+ raise AuthError("Device authorization timed out before approval.")
254
+
255
+ # -- internals ----------------------------------------------------------
256
+
257
+ def _refresh(self, refresh_token: str) -> TokenSet | None:
258
+ """Exchange a refresh token for a new token set, or ``None`` on failure."""
259
+ resp = self._http.post(
260
+ self._settings.token_url,
261
+ data={
262
+ "grant_type": "refresh_token",
263
+ "client_id": self._settings.client_id,
264
+ "refresh_token": refresh_token,
265
+ },
266
+ )
267
+ if resp.status_code != 200:
268
+ return None
269
+ return self._token_set_from_response(resp.json())
270
+
271
+ def _token_set_from_response(self, data: dict[str, Any]) -> TokenSet:
272
+ """Build a :class:`TokenSet` from a token-endpoint JSON response."""
273
+ expires_in = float(data.get("expires_in", 300))
274
+ return TokenSet(
275
+ access_token=str(data["access_token"]),
276
+ refresh_token=(
277
+ str(data["refresh_token"]) if data.get("refresh_token") else None
278
+ ),
279
+ expires_at=self._now() + expires_in,
280
+ )
281
+
282
+ @staticmethod
283
+ def _error_code(resp: httpx.Response) -> str:
284
+ """Extract the OAuth ``error`` code from a non-200 token response."""
285
+ try:
286
+ return str(resp.json().get("error", "unknown_error"))
287
+ except ValueError:
288
+ return "unknown_error"