@josephyan/qingflow-app-user-mcp 0.2.0-beta.990 → 0.2.0-beta.992
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.
- package/README.md +2 -2
- package/docs/local-agent-install.md +0 -1
- package/npm/lib/runtime.mjs +10 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/cli/commands/auth.py +20 -85
- package/src/qingflow_mcp/cli/oauth_login.py +0 -626
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.992
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.992 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
- 对 stdio MCP 来说,主路径仍然只有 `auth_use_credential`。
|
|
26
26
|
- 如果你是在终端里直接使用 `qingflow` CLI,可以额外使用 `qingflow auth login` 作为“人类登录”入口;默认会提示轻流邮箱和隐藏密码,拿到 `token` 后建立本地 CLI 会话。
|
|
27
|
-
- 如果需要浏览器 Authorization Code + PKCE 或 Device Flow,请显式使用 `qingflow auth login --browser` 或 `qingflow auth login --device`;这条 OAuth 路径最终拿到 `credential` 后再复用 `auth_use_credential` 建会话。
|
|
28
27
|
- 也就是说,这次新增的是 CLI 的登录入口,不是给 MCP 增加第二套会话模型。
|
|
29
28
|
|
|
30
29
|
## npm 安装器适用场景
|
package/npm/lib/runtime.mjs
CHANGED
|
@@ -287,7 +287,12 @@ function forwardSignal(child, signal) {
|
|
|
287
287
|
});
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
export function spawnServer(
|
|
290
|
+
export function spawnServer(
|
|
291
|
+
packageRoot,
|
|
292
|
+
args,
|
|
293
|
+
commandName = "qingflow-mcp",
|
|
294
|
+
{ allowRuntimeBootstrap = false, stdio = "proxy" } = {},
|
|
295
|
+
) {
|
|
291
296
|
let runtime = inspectPythonEnv(packageRoot, commandName);
|
|
292
297
|
let serverCommand = runtime.serverCommand;
|
|
293
298
|
|
|
@@ -315,12 +320,14 @@ export function spawnServer(packageRoot, args, commandName = "qingflow-mcp", { a
|
|
|
315
320
|
}
|
|
316
321
|
|
|
317
322
|
const child = spawn(serverCommand, args, {
|
|
318
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
323
|
+
stdio: stdio === "inherit" ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
319
324
|
env: process.env,
|
|
320
325
|
windowsHide: true,
|
|
321
326
|
});
|
|
322
327
|
|
|
323
|
-
|
|
328
|
+
if (stdio !== "inherit") {
|
|
329
|
+
proxyStreams(child);
|
|
330
|
+
}
|
|
324
331
|
forwardSignal(child, "SIGINT");
|
|
325
332
|
forwardSignal(child, "SIGTERM");
|
|
326
333
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -6,7 +6,6 @@ import sys
|
|
|
6
6
|
|
|
7
7
|
from ...errors import QingflowApiError
|
|
8
8
|
from ..context import CliContext
|
|
9
|
-
from ..oauth_login import login_with_cli_oauth
|
|
10
9
|
from ..qingflow_login import login_with_qingflow_password
|
|
11
10
|
from .common import read_secret_arg
|
|
12
11
|
|
|
@@ -15,17 +14,9 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
15
14
|
parser = subparsers.add_parser("auth", help="认证与会话")
|
|
16
15
|
auth_subparsers = parser.add_subparsers(dest="auth_command", required=True)
|
|
17
16
|
|
|
18
|
-
login = auth_subparsers.add_parser("login", help="登录 CLI
|
|
17
|
+
login = auth_subparsers.add_parser("login", help="登录 CLI:轻流账号密码交互")
|
|
19
18
|
login.add_argument("--base-url")
|
|
20
19
|
login.add_argument("--qf-version")
|
|
21
|
-
login.add_argument("--client-id")
|
|
22
|
-
login.add_argument("--authorization-endpoint")
|
|
23
|
-
login.add_argument("--token-endpoint")
|
|
24
|
-
login.add_argument("--device-authorization-endpoint")
|
|
25
|
-
login.add_argument("--scope")
|
|
26
|
-
login.add_argument("--credential-field")
|
|
27
|
-
login.add_argument("--browser", action="store_true", help="使用浏览器 OAuth 登录;无浏览器时可配合 --device")
|
|
28
|
-
login.add_argument("--device", action="store_true", help="使用 OAuth Device Flow(隐含 --browser)")
|
|
29
20
|
login.add_argument("--email", help="轻流账号邮箱;不传时在交互终端提示输入")
|
|
30
21
|
login.add_argument("--password", help="轻流账号密码;建议仅用于本地调试,脚本请优先使用 --password-stdin")
|
|
31
22
|
login.add_argument("--password-stdin", action="store_true", help="从标准输入读取轻流账号密码")
|
|
@@ -49,86 +40,30 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
49
40
|
|
|
50
41
|
|
|
51
42
|
def _handle_login(args: argparse.Namespace, context: CliContext) -> dict:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
result = context.auth.auth_use_credential(
|
|
69
|
-
profile=args.profile,
|
|
70
|
-
base_url=args.base_url,
|
|
71
|
-
qf_version=args.qf_version,
|
|
72
|
-
credential=oauth_result.credential,
|
|
73
|
-
persist=bool(args.persist),
|
|
74
|
-
)
|
|
75
|
-
login_summary = {
|
|
76
|
-
"flow": oauth_result.flow,
|
|
77
|
-
"authorize_url": oauth_result.authorize_url,
|
|
78
|
-
"verification_uri": oauth_result.verification_uri,
|
|
79
|
-
"user_code": oauth_result.user_code,
|
|
80
|
-
}
|
|
81
|
-
else:
|
|
82
|
-
email = _resolve_login_email(args)
|
|
83
|
-
password = _resolve_login_password(args)
|
|
84
|
-
login_result = login_with_qingflow_password(
|
|
85
|
-
base_url=args.base_url,
|
|
86
|
-
email=email,
|
|
87
|
-
password=password,
|
|
88
|
-
)
|
|
89
|
-
result = context.auth.auth_use_token(
|
|
90
|
-
profile=args.profile,
|
|
91
|
-
base_url=args.base_url,
|
|
92
|
-
qf_version=args.qf_version,
|
|
93
|
-
token=login_result.token,
|
|
94
|
-
login_token=login_result.login_token,
|
|
95
|
-
user_info=login_result.user_info,
|
|
96
|
-
persist=bool(args.persist),
|
|
97
|
-
)
|
|
98
|
-
login_summary = {
|
|
99
|
-
"flow": login_result.flow,
|
|
100
|
-
"authorize_url": None,
|
|
101
|
-
"verification_uri": None,
|
|
102
|
-
"user_code": None,
|
|
103
|
-
}
|
|
43
|
+
email = _resolve_login_email(args)
|
|
44
|
+
password = _resolve_login_password(args)
|
|
45
|
+
login_result = login_with_qingflow_password(
|
|
46
|
+
base_url=args.base_url,
|
|
47
|
+
email=email,
|
|
48
|
+
password=password,
|
|
49
|
+
)
|
|
50
|
+
result = context.auth.auth_use_token(
|
|
51
|
+
profile=args.profile,
|
|
52
|
+
base_url=args.base_url,
|
|
53
|
+
qf_version=args.qf_version,
|
|
54
|
+
token=login_result.token,
|
|
55
|
+
login_token=login_result.login_token,
|
|
56
|
+
user_info=login_result.user_info,
|
|
57
|
+
persist=bool(args.persist),
|
|
58
|
+
)
|
|
104
59
|
warnings = list(result.get("warnings") or []) if isinstance(result, dict) else []
|
|
105
60
|
if isinstance(result, dict):
|
|
106
|
-
result["cli_auth"] = {
|
|
61
|
+
result["cli_auth"] = {"flow": login_result.flow}
|
|
107
62
|
if warnings:
|
|
108
63
|
result["warnings"] = warnings
|
|
109
64
|
return result
|
|
110
65
|
|
|
111
66
|
|
|
112
|
-
def _should_use_browser_login(args: argparse.Namespace) -> bool:
|
|
113
|
-
return any(
|
|
114
|
-
bool(getattr(args, name, None))
|
|
115
|
-
for name in (
|
|
116
|
-
"browser",
|
|
117
|
-
"device",
|
|
118
|
-
"client_id",
|
|
119
|
-
"authorization_endpoint",
|
|
120
|
-
"token_endpoint",
|
|
121
|
-
"device_authorization_endpoint",
|
|
122
|
-
"scope",
|
|
123
|
-
"credential_field",
|
|
124
|
-
)
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _has_account_login_inputs(args: argparse.Namespace) -> bool:
|
|
129
|
-
return bool(args.email or args.password or bool(args.password_stdin))
|
|
130
|
-
|
|
131
|
-
|
|
132
67
|
def _resolve_login_email(args: argparse.Namespace) -> str:
|
|
133
68
|
email = str(args.email or "").strip()
|
|
134
69
|
if email:
|
|
@@ -136,7 +71,7 @@ def _resolve_login_email(args: argparse.Namespace) -> str:
|
|
|
136
71
|
if sys.stdin.isatty():
|
|
137
72
|
return input("Qingflow email: ").strip()
|
|
138
73
|
raise QingflowApiError.config_error(
|
|
139
|
-
"qingflow auth login needs an interactive terminal, or pass --email with --password-stdin
|
|
74
|
+
"qingflow auth login needs an interactive terminal, or pass --email with --password-stdin."
|
|
140
75
|
)
|
|
141
76
|
|
|
142
77
|
|
|
@@ -150,7 +85,7 @@ def _resolve_login_password(args: argparse.Namespace) -> str:
|
|
|
150
85
|
if sys.stdin.isatty():
|
|
151
86
|
return getpass.getpass("Qingflow password: ")
|
|
152
87
|
raise QingflowApiError.config_error(
|
|
153
|
-
"qingflow auth login needs an interactive terminal
|
|
88
|
+
"qingflow auth login needs an interactive terminal or --password-stdin."
|
|
154
89
|
)
|
|
155
90
|
|
|
156
91
|
|
|
@@ -1,626 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import base64
|
|
4
|
-
import hashlib
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
import threading
|
|
9
|
-
import time
|
|
10
|
-
import webbrowser
|
|
11
|
-
from dataclasses import dataclass
|
|
12
|
-
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
|
-
from typing import Any
|
|
14
|
-
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
|
|
15
|
-
from uuid import uuid4
|
|
16
|
-
|
|
17
|
-
import httpx
|
|
18
|
-
|
|
19
|
-
from ..config import get_config_value, get_timeout_seconds
|
|
20
|
-
from ..errors import QingflowApiError
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
_DEFAULT_SCOPE = "openid profile email"
|
|
24
|
-
_DEFAULT_CALLBACK_HOST = "127.0.0.1"
|
|
25
|
-
_DEFAULT_CALLBACK_PATH = "/callback"
|
|
26
|
-
_DEFAULT_BROWSER_TIMEOUT_SECONDS = 180.0
|
|
27
|
-
_DEFAULT_DEVICE_INTERVAL_SECONDS = 5.0
|
|
28
|
-
_SUCCESS_HTML = """<!doctype html>
|
|
29
|
-
<html lang="en">
|
|
30
|
-
<head>
|
|
31
|
-
<meta charset="utf-8">
|
|
32
|
-
<title>Qingflow CLI Login Complete</title>
|
|
33
|
-
</head>
|
|
34
|
-
<body>
|
|
35
|
-
<p>Qingflow CLI login complete. You can return to the terminal.</p>
|
|
36
|
-
</body>
|
|
37
|
-
</html>
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass(slots=True)
|
|
42
|
-
class CliOAuthConfig:
|
|
43
|
-
client_id: str
|
|
44
|
-
authorization_endpoint: str
|
|
45
|
-
token_endpoint: str
|
|
46
|
-
device_authorization_endpoint: str | None
|
|
47
|
-
scope: str
|
|
48
|
-
callback_host: str
|
|
49
|
-
callback_port: int
|
|
50
|
-
callback_path: str
|
|
51
|
-
browser_timeout_seconds: float
|
|
52
|
-
credential_field: str
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@dataclass(slots=True)
|
|
56
|
-
class CliOAuthCredential:
|
|
57
|
-
credential: str
|
|
58
|
-
flow: str
|
|
59
|
-
authorize_url: str | None = None
|
|
60
|
-
verification_uri: str | None = None
|
|
61
|
-
user_code: str | None = None
|
|
62
|
-
warnings: list[str] | None = None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class _LoopbackAuthServer:
|
|
66
|
-
def __init__(
|
|
67
|
-
self,
|
|
68
|
-
*,
|
|
69
|
-
host: str,
|
|
70
|
-
port: int,
|
|
71
|
-
callback_path: str,
|
|
72
|
-
timeout_seconds: float,
|
|
73
|
-
status_stream,
|
|
74
|
-
) -> None:
|
|
75
|
-
self._host = host
|
|
76
|
-
self._port = port
|
|
77
|
-
self._callback_path = callback_path
|
|
78
|
-
self._timeout_seconds = timeout_seconds
|
|
79
|
-
self._status_stream = status_stream
|
|
80
|
-
self._event = threading.Event()
|
|
81
|
-
self._params: dict[str, str] | None = None
|
|
82
|
-
self._error: str | None = None
|
|
83
|
-
self._server = self._build_server()
|
|
84
|
-
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
85
|
-
|
|
86
|
-
@property
|
|
87
|
-
def redirect_uri(self) -> str:
|
|
88
|
-
return f"http://{self._host}:{self._server.server_port}{self._callback_path}"
|
|
89
|
-
|
|
90
|
-
def start(self) -> None:
|
|
91
|
-
self._thread.start()
|
|
92
|
-
|
|
93
|
-
def wait(self) -> dict[str, str]:
|
|
94
|
-
if not self._event.wait(timeout=self._timeout_seconds):
|
|
95
|
-
raise QingflowApiError.config_error(
|
|
96
|
-
f"timed out waiting for browser callback after {int(self._timeout_seconds)} seconds"
|
|
97
|
-
)
|
|
98
|
-
if self._error:
|
|
99
|
-
raise QingflowApiError(category="auth", message=self._error)
|
|
100
|
-
if self._params is None:
|
|
101
|
-
raise QingflowApiError(category="auth", message="browser callback did not return any parameters")
|
|
102
|
-
return self._params
|
|
103
|
-
|
|
104
|
-
def close(self) -> None:
|
|
105
|
-
self._server.shutdown()
|
|
106
|
-
self._server.server_close()
|
|
107
|
-
self._thread.join(timeout=1)
|
|
108
|
-
|
|
109
|
-
def _build_server(self) -> HTTPServer:
|
|
110
|
-
parent = self
|
|
111
|
-
|
|
112
|
-
class CallbackServer(HTTPServer):
|
|
113
|
-
allow_reuse_address = True
|
|
114
|
-
|
|
115
|
-
class Handler(BaseHTTPRequestHandler):
|
|
116
|
-
def do_GET(self) -> None: # noqa: N802
|
|
117
|
-
parsed = urlparse(self.path)
|
|
118
|
-
if parsed.path != parent._callback_path:
|
|
119
|
-
self.send_error(404)
|
|
120
|
-
return
|
|
121
|
-
query = {
|
|
122
|
-
key: values[0]
|
|
123
|
-
for key, values in parse_qs(parsed.query, keep_blank_values=True).items()
|
|
124
|
-
if values
|
|
125
|
-
}
|
|
126
|
-
parent._params = query
|
|
127
|
-
parent._event.set()
|
|
128
|
-
self.send_response(200)
|
|
129
|
-
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
130
|
-
self.end_headers()
|
|
131
|
-
self.wfile.write(_SUCCESS_HTML.encode("utf-8"))
|
|
132
|
-
|
|
133
|
-
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
try:
|
|
137
|
-
return CallbackServer((self._host, self._port), Handler)
|
|
138
|
-
except OSError as exc:
|
|
139
|
-
raise QingflowApiError.config_error(f"failed to start local callback server: {exc}") from exc
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
class CliOAuthLoginHelper:
|
|
143
|
-
def __init__(
|
|
144
|
-
self,
|
|
145
|
-
*,
|
|
146
|
-
http_client: httpx.Client | None = None,
|
|
147
|
-
browser_opener=webbrowser.open,
|
|
148
|
-
sleep_fn=time.sleep,
|
|
149
|
-
status_stream=None,
|
|
150
|
-
) -> None:
|
|
151
|
-
self._owns_client = http_client is None
|
|
152
|
-
self._http_client = http_client or httpx.Client(
|
|
153
|
-
timeout=get_timeout_seconds(),
|
|
154
|
-
follow_redirects=True,
|
|
155
|
-
trust_env=False,
|
|
156
|
-
)
|
|
157
|
-
self._browser_opener = browser_opener
|
|
158
|
-
self._sleep = sleep_fn
|
|
159
|
-
self._status_stream = status_stream or sys.stderr
|
|
160
|
-
|
|
161
|
-
def close(self) -> None:
|
|
162
|
-
if self._owns_client:
|
|
163
|
-
self._http_client.close()
|
|
164
|
-
|
|
165
|
-
def login(
|
|
166
|
-
self,
|
|
167
|
-
*,
|
|
168
|
-
base_url: str | None,
|
|
169
|
-
client_id: str | None = None,
|
|
170
|
-
authorization_endpoint: str | None = None,
|
|
171
|
-
token_endpoint: str | None = None,
|
|
172
|
-
device_authorization_endpoint: str | None = None,
|
|
173
|
-
scope: str | None = None,
|
|
174
|
-
credential_field: str | None = None,
|
|
175
|
-
force_device: bool = False,
|
|
176
|
-
) -> CliOAuthCredential:
|
|
177
|
-
config = load_cli_oauth_config(
|
|
178
|
-
base_url=base_url,
|
|
179
|
-
client_id=client_id,
|
|
180
|
-
authorization_endpoint=authorization_endpoint,
|
|
181
|
-
token_endpoint=token_endpoint,
|
|
182
|
-
device_authorization_endpoint=device_authorization_endpoint,
|
|
183
|
-
scope=scope,
|
|
184
|
-
credential_field=credential_field,
|
|
185
|
-
)
|
|
186
|
-
if force_device:
|
|
187
|
-
return self._device_flow(config)
|
|
188
|
-
try:
|
|
189
|
-
return self._authorization_code_flow(config)
|
|
190
|
-
except _BrowserUnavailableError as exc:
|
|
191
|
-
if not config.device_authorization_endpoint:
|
|
192
|
-
raise QingflowApiError.config_error(
|
|
193
|
-
"browser auth is unavailable and no device_authorization_endpoint is configured"
|
|
194
|
-
) from exc
|
|
195
|
-
self._write_status("Browser unavailable, switching to device flow.")
|
|
196
|
-
return self._device_flow(config)
|
|
197
|
-
|
|
198
|
-
def _authorization_code_flow(self, config: CliOAuthConfig) -> CliOAuthCredential:
|
|
199
|
-
state = uuid4().hex
|
|
200
|
-
code_verifier = _generate_code_verifier()
|
|
201
|
-
code_challenge = _code_challenge_s256(code_verifier)
|
|
202
|
-
server = _LoopbackAuthServer(
|
|
203
|
-
host=config.callback_host,
|
|
204
|
-
port=config.callback_port,
|
|
205
|
-
callback_path=config.callback_path,
|
|
206
|
-
timeout_seconds=config.browser_timeout_seconds,
|
|
207
|
-
status_stream=self._status_stream,
|
|
208
|
-
)
|
|
209
|
-
try:
|
|
210
|
-
server.start()
|
|
211
|
-
authorize_url = self._build_authorize_url(
|
|
212
|
-
config=config,
|
|
213
|
-
redirect_uri=server.redirect_uri,
|
|
214
|
-
state=state,
|
|
215
|
-
code_challenge=code_challenge,
|
|
216
|
-
)
|
|
217
|
-
self._write_status(f"Opening browser for Qingflow login: {authorize_url}")
|
|
218
|
-
opened = False
|
|
219
|
-
try:
|
|
220
|
-
opened = bool(self._browser_opener(authorize_url))
|
|
221
|
-
except Exception as exc: # pragma: no cover - depends on local browser runtime
|
|
222
|
-
raise _BrowserUnavailableError(str(exc)) from exc
|
|
223
|
-
if not opened:
|
|
224
|
-
raise _BrowserUnavailableError("system browser refused to open authorization URL")
|
|
225
|
-
callback_params = server.wait()
|
|
226
|
-
finally:
|
|
227
|
-
server.close()
|
|
228
|
-
|
|
229
|
-
if callback_params.get("error"):
|
|
230
|
-
error_description = callback_params.get("error_description") or callback_params["error"]
|
|
231
|
-
raise QingflowApiError(category="auth", message=f"authorization failed: {error_description}")
|
|
232
|
-
returned_state = str(callback_params.get("state") or "")
|
|
233
|
-
if returned_state != state:
|
|
234
|
-
raise QingflowApiError(category="auth", message="authorization callback state mismatch")
|
|
235
|
-
credential = self._extract_credential(callback_params, credential_field=config.credential_field)
|
|
236
|
-
if credential:
|
|
237
|
-
return CliOAuthCredential(
|
|
238
|
-
credential=credential,
|
|
239
|
-
flow="authorization_code",
|
|
240
|
-
authorize_url=authorize_url,
|
|
241
|
-
)
|
|
242
|
-
code = str(callback_params.get("code") or "").strip()
|
|
243
|
-
if not code:
|
|
244
|
-
raise QingflowApiError(category="auth", message="authorization callback did not return a code")
|
|
245
|
-
token_payload = self._post_form(
|
|
246
|
-
config.token_endpoint,
|
|
247
|
-
{
|
|
248
|
-
"grant_type": "authorization_code",
|
|
249
|
-
"client_id": config.client_id,
|
|
250
|
-
"code": code,
|
|
251
|
-
"redirect_uri": server.redirect_uri,
|
|
252
|
-
"code_verifier": code_verifier,
|
|
253
|
-
},
|
|
254
|
-
)
|
|
255
|
-
credential = self._extract_credential(token_payload, credential_field=config.credential_field)
|
|
256
|
-
if not credential:
|
|
257
|
-
raise QingflowApiError(
|
|
258
|
-
category="auth",
|
|
259
|
-
message=(
|
|
260
|
-
"token exchange succeeded but did not return a Qingflow credential; "
|
|
261
|
-
"configure cli_auth.credential_field if your broker uses a custom field"
|
|
262
|
-
),
|
|
263
|
-
)
|
|
264
|
-
return CliOAuthCredential(
|
|
265
|
-
credential=credential,
|
|
266
|
-
flow="authorization_code",
|
|
267
|
-
authorize_url=authorize_url,
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
def _device_flow(self, config: CliOAuthConfig) -> CliOAuthCredential:
|
|
271
|
-
if not config.device_authorization_endpoint:
|
|
272
|
-
raise QingflowApiError.config_error("device_authorization_endpoint is required for device flow")
|
|
273
|
-
start_payload = self._post_form(
|
|
274
|
-
config.device_authorization_endpoint,
|
|
275
|
-
{
|
|
276
|
-
"client_id": config.client_id,
|
|
277
|
-
"scope": config.scope,
|
|
278
|
-
},
|
|
279
|
-
)
|
|
280
|
-
device_code = str(start_payload.get("device_code") or "").strip()
|
|
281
|
-
if not device_code:
|
|
282
|
-
raise QingflowApiError(category="auth", message="device authorization did not return device_code")
|
|
283
|
-
verification_uri = (
|
|
284
|
-
str(start_payload.get("verification_uri_complete") or "").strip()
|
|
285
|
-
or str(start_payload.get("verification_uri") or "").strip()
|
|
286
|
-
)
|
|
287
|
-
user_code = str(start_payload.get("user_code") or "").strip() or None
|
|
288
|
-
interval_seconds = _coerce_positive_float(start_payload.get("interval")) or _DEFAULT_DEVICE_INTERVAL_SECONDS
|
|
289
|
-
expires_in_seconds = _coerce_positive_float(start_payload.get("expires_in")) or config.browser_timeout_seconds
|
|
290
|
-
if verification_uri:
|
|
291
|
-
self._write_status(f"Open this URL to continue Qingflow login: {verification_uri}")
|
|
292
|
-
if user_code:
|
|
293
|
-
self._write_status(f"Device code: {user_code}")
|
|
294
|
-
deadline = time.monotonic() + expires_in_seconds
|
|
295
|
-
interval = interval_seconds
|
|
296
|
-
while time.monotonic() < deadline:
|
|
297
|
-
payload, status_code = self._post_form_with_status(
|
|
298
|
-
config.token_endpoint,
|
|
299
|
-
{
|
|
300
|
-
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
301
|
-
"device_code": device_code,
|
|
302
|
-
"client_id": config.client_id,
|
|
303
|
-
},
|
|
304
|
-
)
|
|
305
|
-
if 200 <= status_code < 300:
|
|
306
|
-
credential = self._extract_credential(payload, credential_field=config.credential_field)
|
|
307
|
-
if not credential:
|
|
308
|
-
raise QingflowApiError(
|
|
309
|
-
category="auth",
|
|
310
|
-
message=(
|
|
311
|
-
"device token exchange succeeded but did not return a Qingflow credential; "
|
|
312
|
-
"configure cli_auth.credential_field if your broker uses a custom field"
|
|
313
|
-
),
|
|
314
|
-
)
|
|
315
|
-
return CliOAuthCredential(
|
|
316
|
-
credential=credential,
|
|
317
|
-
flow="device_code",
|
|
318
|
-
verification_uri=verification_uri or None,
|
|
319
|
-
user_code=user_code,
|
|
320
|
-
)
|
|
321
|
-
error_code = str(payload.get("error") or "").strip()
|
|
322
|
-
if error_code == "authorization_pending":
|
|
323
|
-
self._sleep(interval)
|
|
324
|
-
continue
|
|
325
|
-
if error_code == "slow_down":
|
|
326
|
-
interval += interval_seconds
|
|
327
|
-
self._sleep(interval)
|
|
328
|
-
continue
|
|
329
|
-
if error_code:
|
|
330
|
-
error_description = str(payload.get("error_description") or error_code).strip()
|
|
331
|
-
raise QingflowApiError(category="auth", message=f"device flow failed: {error_description}")
|
|
332
|
-
raise QingflowApiError(
|
|
333
|
-
category="auth",
|
|
334
|
-
message=f"device flow token polling failed with HTTP {status_code}",
|
|
335
|
-
)
|
|
336
|
-
raise QingflowApiError(
|
|
337
|
-
category="auth",
|
|
338
|
-
message="device flow expired before authorization completed",
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
def _build_authorize_url(
|
|
342
|
-
self,
|
|
343
|
-
*,
|
|
344
|
-
config: CliOAuthConfig,
|
|
345
|
-
redirect_uri: str,
|
|
346
|
-
state: str,
|
|
347
|
-
code_challenge: str,
|
|
348
|
-
) -> str:
|
|
349
|
-
query = urlencode(
|
|
350
|
-
{
|
|
351
|
-
"response_type": "code",
|
|
352
|
-
"client_id": config.client_id,
|
|
353
|
-
"redirect_uri": redirect_uri,
|
|
354
|
-
"scope": config.scope,
|
|
355
|
-
"state": state,
|
|
356
|
-
"code_challenge": code_challenge,
|
|
357
|
-
"code_challenge_method": "S256",
|
|
358
|
-
}
|
|
359
|
-
)
|
|
360
|
-
separator = "&" if "?" in config.authorization_endpoint else "?"
|
|
361
|
-
return f"{config.authorization_endpoint}{separator}{query}"
|
|
362
|
-
|
|
363
|
-
def _post_form(self, url: str, form: dict[str, str]) -> dict[str, Any]:
|
|
364
|
-
payload, status_code = self._post_form_with_status(url, form)
|
|
365
|
-
if status_code >= 400:
|
|
366
|
-
error_text = str(payload.get("error_description") or payload.get("message") or payload.get("error") or "")
|
|
367
|
-
raise QingflowApiError(category="auth", message=error_text or f"HTTP {status_code}")
|
|
368
|
-
return payload
|
|
369
|
-
|
|
370
|
-
def _post_form_with_status(self, url: str, form: dict[str, str]) -> tuple[dict[str, Any], int]:
|
|
371
|
-
try:
|
|
372
|
-
response = self._http_client.post(
|
|
373
|
-
url,
|
|
374
|
-
data=form,
|
|
375
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
376
|
-
)
|
|
377
|
-
except httpx.RequestError as exc:
|
|
378
|
-
raise QingflowApiError(category="network", message=str(exc)) from exc
|
|
379
|
-
return _parse_response_payload(response), response.status_code
|
|
380
|
-
|
|
381
|
-
def _extract_credential(self, payload: dict[str, Any], *, credential_field: str) -> str | None:
|
|
382
|
-
if not payload:
|
|
383
|
-
return None
|
|
384
|
-
candidate = _extract_nested_field(payload, credential_field)
|
|
385
|
-
if candidate:
|
|
386
|
-
return candidate
|
|
387
|
-
for alias in ("credential", "qingflow_credential", "x-qingflow-client-id"):
|
|
388
|
-
candidate = _extract_nested_field(payload, alias)
|
|
389
|
-
if candidate:
|
|
390
|
-
return candidate
|
|
391
|
-
return None
|
|
392
|
-
|
|
393
|
-
def _write_status(self, message: str) -> None:
|
|
394
|
-
self._status_stream.write(message.rstrip() + "\n")
|
|
395
|
-
self._status_stream.flush()
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
class _BrowserUnavailableError(RuntimeError):
|
|
399
|
-
pass
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def login_with_cli_oauth(
|
|
403
|
-
*,
|
|
404
|
-
base_url: str | None,
|
|
405
|
-
client_id: str | None = None,
|
|
406
|
-
authorization_endpoint: str | None = None,
|
|
407
|
-
token_endpoint: str | None = None,
|
|
408
|
-
device_authorization_endpoint: str | None = None,
|
|
409
|
-
scope: str | None = None,
|
|
410
|
-
credential_field: str | None = None,
|
|
411
|
-
force_device: bool = False,
|
|
412
|
-
) -> CliOAuthCredential:
|
|
413
|
-
helper = CliOAuthLoginHelper()
|
|
414
|
-
try:
|
|
415
|
-
return helper.login(
|
|
416
|
-
base_url=base_url,
|
|
417
|
-
client_id=client_id,
|
|
418
|
-
authorization_endpoint=authorization_endpoint,
|
|
419
|
-
token_endpoint=token_endpoint,
|
|
420
|
-
device_authorization_endpoint=device_authorization_endpoint,
|
|
421
|
-
scope=scope,
|
|
422
|
-
credential_field=credential_field,
|
|
423
|
-
force_device=force_device,
|
|
424
|
-
)
|
|
425
|
-
finally:
|
|
426
|
-
helper.close()
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def load_cli_oauth_config(
|
|
430
|
-
*,
|
|
431
|
-
base_url: str | None,
|
|
432
|
-
client_id: str | None = None,
|
|
433
|
-
authorization_endpoint: str | None = None,
|
|
434
|
-
token_endpoint: str | None = None,
|
|
435
|
-
device_authorization_endpoint: str | None = None,
|
|
436
|
-
scope: str | None = None,
|
|
437
|
-
credential_field: str | None = None,
|
|
438
|
-
) -> CliOAuthConfig:
|
|
439
|
-
resolved_base_url = str(base_url or "").strip() or None
|
|
440
|
-
resolved_client_id = _first_text(
|
|
441
|
-
client_id,
|
|
442
|
-
os.getenv("QINGFLOW_CLI_AUTH_CLIENT_ID"),
|
|
443
|
-
get_config_value("cli_auth.client_id", default=None),
|
|
444
|
-
)
|
|
445
|
-
resolved_authorization_endpoint = _resolve_endpoint(
|
|
446
|
-
authorization_endpoint,
|
|
447
|
-
resolved_base_url,
|
|
448
|
-
env_var="QINGFLOW_CLI_AUTH_AUTHORIZATION_ENDPOINT",
|
|
449
|
-
config_key="cli_auth.authorization_endpoint",
|
|
450
|
-
)
|
|
451
|
-
resolved_token_endpoint = _resolve_endpoint(
|
|
452
|
-
token_endpoint,
|
|
453
|
-
resolved_base_url,
|
|
454
|
-
env_var="QINGFLOW_CLI_AUTH_TOKEN_ENDPOINT",
|
|
455
|
-
config_key="cli_auth.token_endpoint",
|
|
456
|
-
)
|
|
457
|
-
resolved_device_endpoint = _resolve_endpoint(
|
|
458
|
-
device_authorization_endpoint,
|
|
459
|
-
resolved_base_url,
|
|
460
|
-
env_var="QINGFLOW_CLI_AUTH_DEVICE_AUTHORIZATION_ENDPOINT",
|
|
461
|
-
config_key="cli_auth.device_authorization_endpoint",
|
|
462
|
-
required=False,
|
|
463
|
-
)
|
|
464
|
-
resolved_scope = _first_text(
|
|
465
|
-
scope,
|
|
466
|
-
os.getenv("QINGFLOW_CLI_AUTH_SCOPE"),
|
|
467
|
-
get_config_value("cli_auth.scope", default=None),
|
|
468
|
-
fallback=_DEFAULT_SCOPE,
|
|
469
|
-
)
|
|
470
|
-
callback_host = _first_text(
|
|
471
|
-
os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_HOST"),
|
|
472
|
-
get_config_value("cli_auth.callback_host", default=None),
|
|
473
|
-
fallback=_DEFAULT_CALLBACK_HOST,
|
|
474
|
-
)
|
|
475
|
-
callback_path = _normalize_callback_path(
|
|
476
|
-
_first_text(
|
|
477
|
-
os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_PATH"),
|
|
478
|
-
get_config_value("cli_auth.callback_path", default=None),
|
|
479
|
-
fallback=_DEFAULT_CALLBACK_PATH,
|
|
480
|
-
)
|
|
481
|
-
)
|
|
482
|
-
callback_port = _coerce_port(
|
|
483
|
-
os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_PORT")
|
|
484
|
-
or get_config_value("cli_auth.callback_port", default=0)
|
|
485
|
-
)
|
|
486
|
-
browser_timeout_seconds = _coerce_positive_float(
|
|
487
|
-
os.getenv("QINGFLOW_CLI_AUTH_BROWSER_TIMEOUT_SECONDS")
|
|
488
|
-
or get_config_value("cli_auth.browser_timeout_seconds", default=_DEFAULT_BROWSER_TIMEOUT_SECONDS)
|
|
489
|
-
) or _DEFAULT_BROWSER_TIMEOUT_SECONDS
|
|
490
|
-
resolved_credential_field = _first_text(
|
|
491
|
-
credential_field,
|
|
492
|
-
os.getenv("QINGFLOW_CLI_AUTH_CREDENTIAL_FIELD"),
|
|
493
|
-
get_config_value("cli_auth.credential_field", default=None),
|
|
494
|
-
fallback="credential",
|
|
495
|
-
)
|
|
496
|
-
|
|
497
|
-
missing: list[str] = []
|
|
498
|
-
if not resolved_client_id:
|
|
499
|
-
missing.append("cli_auth.client_id / QINGFLOW_CLI_AUTH_CLIENT_ID")
|
|
500
|
-
if not resolved_authorization_endpoint:
|
|
501
|
-
missing.append("cli_auth.authorization_endpoint / QINGFLOW_CLI_AUTH_AUTHORIZATION_ENDPOINT")
|
|
502
|
-
if not resolved_token_endpoint:
|
|
503
|
-
missing.append("cli_auth.token_endpoint / QINGFLOW_CLI_AUTH_TOKEN_ENDPOINT")
|
|
504
|
-
if missing:
|
|
505
|
-
raise QingflowApiError.config_error(
|
|
506
|
-
"CLI auth login requires OAuth config: " + ", ".join(missing)
|
|
507
|
-
)
|
|
508
|
-
|
|
509
|
-
return CliOAuthConfig(
|
|
510
|
-
client_id=resolved_client_id,
|
|
511
|
-
authorization_endpoint=resolved_authorization_endpoint,
|
|
512
|
-
token_endpoint=resolved_token_endpoint,
|
|
513
|
-
device_authorization_endpoint=resolved_device_endpoint,
|
|
514
|
-
scope=resolved_scope,
|
|
515
|
-
callback_host=callback_host,
|
|
516
|
-
callback_port=callback_port,
|
|
517
|
-
callback_path=callback_path,
|
|
518
|
-
browser_timeout_seconds=browser_timeout_seconds,
|
|
519
|
-
credential_field=resolved_credential_field,
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def _resolve_endpoint(
|
|
524
|
-
override: str | None,
|
|
525
|
-
base_url: str | None,
|
|
526
|
-
*,
|
|
527
|
-
env_var: str,
|
|
528
|
-
config_key: str,
|
|
529
|
-
required: bool = True,
|
|
530
|
-
) -> str | None:
|
|
531
|
-
value = _first_text(
|
|
532
|
-
override,
|
|
533
|
-
os.getenv(env_var),
|
|
534
|
-
get_config_value(config_key, default=None),
|
|
535
|
-
)
|
|
536
|
-
if not value:
|
|
537
|
-
return None if not required else ""
|
|
538
|
-
parsed = urlparse(value)
|
|
539
|
-
if parsed.scheme and parsed.netloc:
|
|
540
|
-
return value
|
|
541
|
-
if not base_url:
|
|
542
|
-
raise QingflowApiError.config_error(
|
|
543
|
-
f"{config_key} is relative, but no base_url was provided to resolve it"
|
|
544
|
-
)
|
|
545
|
-
return urljoin(base_url.rstrip("/") + "/", value)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
def _first_text(*values: Any, fallback: str | None = None) -> str | None:
|
|
549
|
-
for value in values:
|
|
550
|
-
text = str(value or "").strip()
|
|
551
|
-
if text:
|
|
552
|
-
return text
|
|
553
|
-
return fallback
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
def _normalize_callback_path(value: str) -> str:
|
|
557
|
-
normalized = str(value or "").strip() or _DEFAULT_CALLBACK_PATH
|
|
558
|
-
return normalized if normalized.startswith("/") else f"/{normalized}"
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
def _coerce_positive_float(value: Any) -> float | None:
|
|
562
|
-
if value in (None, ""):
|
|
563
|
-
return None
|
|
564
|
-
try:
|
|
565
|
-
parsed = float(value)
|
|
566
|
-
except (TypeError, ValueError):
|
|
567
|
-
return None
|
|
568
|
-
return parsed if parsed > 0 else None
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def _coerce_port(value: Any) -> int:
|
|
572
|
-
if value in (None, ""):
|
|
573
|
-
return 0
|
|
574
|
-
try:
|
|
575
|
-
parsed = int(value)
|
|
576
|
-
except (TypeError, ValueError):
|
|
577
|
-
raise QingflowApiError.config_error("cli_auth.callback_port must be an integer") from None
|
|
578
|
-
if parsed < 0 or parsed > 65535:
|
|
579
|
-
raise QingflowApiError.config_error("cli_auth.callback_port must be between 0 and 65535")
|
|
580
|
-
return parsed
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
def _generate_code_verifier() -> str:
|
|
584
|
-
random_bytes = os.urandom(32)
|
|
585
|
-
return base64.urlsafe_b64encode(random_bytes).decode("ascii").rstrip("=")
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
def _code_challenge_s256(code_verifier: str) -> str:
|
|
589
|
-
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
590
|
-
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
def _parse_response_payload(response: httpx.Response) -> dict[str, Any]:
|
|
594
|
-
if not response.content:
|
|
595
|
-
return {}
|
|
596
|
-
content_type = str(response.headers.get("Content-Type") or "").lower()
|
|
597
|
-
if "application/json" in content_type:
|
|
598
|
-
payload = response.json()
|
|
599
|
-
return payload if isinstance(payload, dict) else {"value": payload}
|
|
600
|
-
if "application/x-www-form-urlencoded" in content_type:
|
|
601
|
-
return {
|
|
602
|
-
key: values[0]
|
|
603
|
-
for key, values in parse_qs(response.text, keep_blank_values=True).items()
|
|
604
|
-
if values
|
|
605
|
-
}
|
|
606
|
-
try:
|
|
607
|
-
payload = response.json()
|
|
608
|
-
except ValueError:
|
|
609
|
-
try:
|
|
610
|
-
return json.loads(response.text)
|
|
611
|
-
except (TypeError, ValueError):
|
|
612
|
-
return {"message": response.text}
|
|
613
|
-
return payload if isinstance(payload, dict) else {"value": payload}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
def _extract_nested_field(payload: dict[str, Any], key: str) -> str | None:
|
|
617
|
-
value = payload.get(key)
|
|
618
|
-
if isinstance(value, str) and value.strip():
|
|
619
|
-
return value.strip()
|
|
620
|
-
for container_key in ("data", "result", "payload"):
|
|
621
|
-
nested = payload.get(container_key)
|
|
622
|
-
if isinstance(nested, dict):
|
|
623
|
-
nested_value = nested.get(key)
|
|
624
|
-
if isinstance(nested_value, str) and nested_value.strip():
|
|
625
|
-
return nested_value.strip()
|
|
626
|
-
return None
|