@josephyan/qingflow-app-user-mcp 0.2.0-beta.989 → 0.2.0-beta.990
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 +7 -0
- 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 +128 -0
- package/src/qingflow_mcp/cli/formatters.py +7 -0
- package/src/qingflow_mcp/cli/oauth_login.py +626 -0
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/public_surface.py +1 -0
- package/src/qingflow_mcp/tools/auth_tools.py +130 -0
- package/src/qingflow_mcp/tools/base.py +1 -1
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.990
|
|
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.990 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
`auth_use_credential` 是本地唯一鉴权主路径。
|
|
22
22
|
|
|
23
|
+
补充说明:
|
|
24
|
+
|
|
25
|
+
- 对 stdio MCP 来说,主路径仍然只有 `auth_use_credential`。
|
|
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
|
+
- 也就是说,这次新增的是 CLI 的登录入口,不是给 MCP 增加第二套会话模型。
|
|
29
|
+
|
|
23
30
|
## npm 安装器适用场景
|
|
24
31
|
|
|
25
32
|
适合这类本地 agent / gateway:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import sys
|
|
4
6
|
|
|
7
|
+
from ...errors import QingflowApiError
|
|
5
8
|
from ..context import CliContext
|
|
9
|
+
from ..oauth_login import login_with_cli_oauth
|
|
10
|
+
from ..qingflow_login import login_with_qingflow_password
|
|
6
11
|
from .common import read_secret_arg
|
|
7
12
|
|
|
8
13
|
|
|
@@ -10,6 +15,23 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
10
15
|
parser = subparsers.add_parser("auth", help="认证与会话")
|
|
11
16
|
auth_subparsers = parser.add_subparsers(dest="auth_command", required=True)
|
|
12
17
|
|
|
18
|
+
login = auth_subparsers.add_parser("login", help="登录 CLI:默认轻流账号密码交互;--browser 使用 OAuth")
|
|
19
|
+
login.add_argument("--base-url")
|
|
20
|
+
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
|
+
login.add_argument("--email", help="轻流账号邮箱;不传时在交互终端提示输入")
|
|
30
|
+
login.add_argument("--password", help="轻流账号密码;建议仅用于本地调试,脚本请优先使用 --password-stdin")
|
|
31
|
+
login.add_argument("--password-stdin", action="store_true", help="从标准输入读取轻流账号密码")
|
|
32
|
+
login.add_argument("--persist", action=argparse.BooleanOptionalAction, default=True)
|
|
33
|
+
login.set_defaults(handler=_handle_login, format_hint="auth_whoami")
|
|
34
|
+
|
|
13
35
|
use_credential = auth_subparsers.add_parser("use-credential", help="直接注入 credential")
|
|
14
36
|
use_credential.add_argument("--base-url")
|
|
15
37
|
use_credential.add_argument("--qf-version")
|
|
@@ -26,6 +48,112 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
26
48
|
logout.set_defaults(handler=_handle_logout, format_hint="")
|
|
27
49
|
|
|
28
50
|
|
|
51
|
+
def _handle_login(args: argparse.Namespace, context: CliContext) -> dict:
|
|
52
|
+
login_summary: dict[str, str | None]
|
|
53
|
+
if _should_use_browser_login(args):
|
|
54
|
+
if _has_account_login_inputs(args):
|
|
55
|
+
raise QingflowApiError.config_error(
|
|
56
|
+
"Choose either account/password login or --browser OAuth login; do not combine both."
|
|
57
|
+
)
|
|
58
|
+
oauth_result = login_with_cli_oauth(
|
|
59
|
+
base_url=args.base_url,
|
|
60
|
+
client_id=args.client_id,
|
|
61
|
+
authorization_endpoint=args.authorization_endpoint,
|
|
62
|
+
token_endpoint=args.token_endpoint,
|
|
63
|
+
device_authorization_endpoint=args.device_authorization_endpoint,
|
|
64
|
+
scope=args.scope,
|
|
65
|
+
credential_field=args.credential_field,
|
|
66
|
+
force_device=bool(args.device),
|
|
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
|
+
}
|
|
104
|
+
warnings = list(result.get("warnings") or []) if isinstance(result, dict) else []
|
|
105
|
+
if isinstance(result, dict):
|
|
106
|
+
result["cli_auth"] = {key: value for key, value in login_summary.items() if value}
|
|
107
|
+
if warnings:
|
|
108
|
+
result["warnings"] = warnings
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
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
|
+
def _resolve_login_email(args: argparse.Namespace) -> str:
|
|
133
|
+
email = str(args.email or "").strip()
|
|
134
|
+
if email:
|
|
135
|
+
return email
|
|
136
|
+
if sys.stdin.isatty():
|
|
137
|
+
return input("Qingflow email: ").strip()
|
|
138
|
+
raise QingflowApiError.config_error(
|
|
139
|
+
"qingflow auth login needs an interactive terminal, or pass --email with --password-stdin, or use --browser."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_login_password(args: argparse.Namespace) -> str:
|
|
144
|
+
if args.password or bool(args.password_stdin):
|
|
145
|
+
return read_secret_arg(
|
|
146
|
+
args.password,
|
|
147
|
+
stdin_enabled=bool(args.password_stdin),
|
|
148
|
+
label="password",
|
|
149
|
+
)
|
|
150
|
+
if sys.stdin.isatty():
|
|
151
|
+
return getpass.getpass("Qingflow password: ")
|
|
152
|
+
raise QingflowApiError.config_error(
|
|
153
|
+
"qingflow auth login needs an interactive terminal, --password-stdin, or --browser."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
29
157
|
def _handle_use_credential(args: argparse.Namespace, context: CliContext) -> dict:
|
|
30
158
|
credential = (
|
|
31
159
|
read_secret_arg(args.credential, stdin_enabled=bool(args.credential_stdin), label="credential")
|
|
@@ -40,6 +40,13 @@ def _format_whoami(result: dict[str, Any]) -> str:
|
|
|
40
40
|
f"Workspace: {result.get('selected_ws_name') or '-'} ({result.get('selected_ws_id') or '-'})",
|
|
41
41
|
f"Workspace QF Version: {result.get('qf_version') or '-'}",
|
|
42
42
|
]
|
|
43
|
+
cli_auth = result.get("cli_auth") if isinstance(result.get("cli_auth"), dict) else {}
|
|
44
|
+
if cli_auth:
|
|
45
|
+
lines.append(f"Login Flow: {cli_auth.get('flow') or '-'}")
|
|
46
|
+
if cli_auth.get("verification_uri"):
|
|
47
|
+
lines.append(f"Verification URL: {cli_auth.get('verification_uri')}")
|
|
48
|
+
if cli_auth.get("user_code"):
|
|
49
|
+
lines.append(f"User Code: {cli_auth.get('user_code')}")
|
|
43
50
|
request_route = result.get("request_route") if isinstance(result.get("request_route"), dict) else {}
|
|
44
51
|
route_qf_version = request_route.get("qf_version")
|
|
45
52
|
if route_qf_version and route_qf_version != result.get("qf_version"):
|
|
@@ -0,0 +1,626 @@
|
|
|
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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import PKCS1_v1_5
|
|
8
|
+
from Crypto.PublicKey import RSA
|
|
9
|
+
|
|
10
|
+
from ..backend_client import BackendClient
|
|
11
|
+
from ..config import get_default_base_url, get_timeout_seconds, normalize_base_url
|
|
12
|
+
from ..errors import QingflowApiError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class QingflowNativeLoginResult:
|
|
17
|
+
token: str
|
|
18
|
+
user_info: dict[str, Any]
|
|
19
|
+
login_token: str | None = None
|
|
20
|
+
flow: str = "qingflow_password"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QingflowNativeLoginHelper:
|
|
24
|
+
def __init__(self, *, backend: BackendClient | None = None) -> None:
|
|
25
|
+
self._owns_backend = backend is None
|
|
26
|
+
self._backend = backend or BackendClient(timeout=get_timeout_seconds())
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
if self._owns_backend:
|
|
30
|
+
self._backend.close()
|
|
31
|
+
|
|
32
|
+
def login_with_password(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str | None,
|
|
36
|
+
email: str,
|
|
37
|
+
password: str,
|
|
38
|
+
) -> QingflowNativeLoginResult:
|
|
39
|
+
normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
|
|
40
|
+
normalized_email = str(email or "").strip()
|
|
41
|
+
normalized_password = str(password or "")
|
|
42
|
+
if not normalized_base_url:
|
|
43
|
+
raise QingflowApiError.config_error("base_url is required or configure default_base_url")
|
|
44
|
+
if not normalized_email:
|
|
45
|
+
raise QingflowApiError.config_error("email is required for Qingflow account login")
|
|
46
|
+
if not normalized_password:
|
|
47
|
+
raise QingflowApiError.config_error("password is required for Qingflow account login")
|
|
48
|
+
|
|
49
|
+
pubkey_payload = self._backend.public_request("GET", normalized_base_url, "/user/pubkey", qf_version=None)
|
|
50
|
+
pubkey = self._extract_pubkey(pubkey_payload)
|
|
51
|
+
encrypted_password = _encrypt_password(normalized_password, pubkey)
|
|
52
|
+
login_payload = self._backend.public_request(
|
|
53
|
+
"POST",
|
|
54
|
+
normalized_base_url,
|
|
55
|
+
"/user/login",
|
|
56
|
+
json_body={"email": normalized_email, "password": encrypted_password},
|
|
57
|
+
qf_version=None,
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(login_payload, dict):
|
|
60
|
+
raise QingflowApiError(category="auth", message="Qingflow login did not return a valid response")
|
|
61
|
+
|
|
62
|
+
token = str(login_payload.get("token") or "").strip()
|
|
63
|
+
login_token = str(login_payload.get("loginToken") or "").strip() or None
|
|
64
|
+
if not token:
|
|
65
|
+
if login_token:
|
|
66
|
+
raise QingflowApiError(
|
|
67
|
+
category="auth",
|
|
68
|
+
message=(
|
|
69
|
+
"Qingflow account login requires additional security verification. "
|
|
70
|
+
"CLI password login currently does not complete the loginToken verification step."
|
|
71
|
+
),
|
|
72
|
+
details={"login_token_present": True},
|
|
73
|
+
)
|
|
74
|
+
raise QingflowApiError(
|
|
75
|
+
category="auth",
|
|
76
|
+
message="Qingflow login succeeded but did not return a token",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
user_info = login_payload.get("userInfo")
|
|
80
|
+
if not isinstance(user_info, dict):
|
|
81
|
+
user_info = {}
|
|
82
|
+
return QingflowNativeLoginResult(
|
|
83
|
+
token=token,
|
|
84
|
+
login_token=login_token,
|
|
85
|
+
user_info=user_info,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _extract_pubkey(self, payload: Any) -> str:
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response is invalid")
|
|
91
|
+
pubkey = str(payload.get("pubkey") or "").strip()
|
|
92
|
+
if not pubkey:
|
|
93
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response did not include pubkey")
|
|
94
|
+
return pubkey
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def login_with_qingflow_password(
|
|
98
|
+
*,
|
|
99
|
+
base_url: str | None,
|
|
100
|
+
email: str,
|
|
101
|
+
password: str,
|
|
102
|
+
) -> QingflowNativeLoginResult:
|
|
103
|
+
helper = QingflowNativeLoginHelper()
|
|
104
|
+
try:
|
|
105
|
+
return helper.login_with_password(base_url=base_url, email=email, password=password)
|
|
106
|
+
finally:
|
|
107
|
+
helper.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _encrypt_password(password: str, pubkey: str) -> str:
|
|
111
|
+
public_key = RSA.import_key(
|
|
112
|
+
"-----BEGIN PUBLIC KEY-----\n" + pubkey.strip() + "\n-----END PUBLIC KEY-----\n"
|
|
113
|
+
)
|
|
114
|
+
cipher = PKCS1_v1_5.new(public_key)
|
|
115
|
+
encrypted = cipher.encrypt(password.encode("utf-8"))
|
|
116
|
+
return base64.b64encode(encrypted).decode("ascii")
|
|
@@ -43,14 +43,14 @@ class QingflowApiError(Exception):
|
|
|
43
43
|
def auth_required(cls, profile: str) -> "QingflowApiError":
|
|
44
44
|
return cls(
|
|
45
45
|
category="auth",
|
|
46
|
-
message=f"Profile '{profile}' is not logged in. Run auth_use_credential first.",
|
|
46
|
+
message=f"Profile '{profile}' is not logged in. Run auth login or auth_use_credential first.",
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
50
|
def workspace_not_selected(cls, profile: str) -> "QingflowApiError":
|
|
51
51
|
return cls(
|
|
52
52
|
category="workspace",
|
|
53
|
-
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth_use_credential.",
|
|
53
|
+
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth login or auth_use_credential.",
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
@classmethod
|
|
@@ -30,6 +30,7 @@ def tool_key(domain: str, tool_name: str) -> str:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
33
|
+
PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
|
|
33
34
|
PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
|
|
34
35
|
PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
|
|
35
36
|
PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
|
|
@@ -164,6 +164,136 @@ class AuthTools(ToolBase):
|
|
|
164
164
|
),
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
def auth_use_token(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
profile: str = DEFAULT_PROFILE,
|
|
171
|
+
base_url: str | None = None,
|
|
172
|
+
qf_version: str | None = None,
|
|
173
|
+
token: str | None = None,
|
|
174
|
+
login_token: str | None = None,
|
|
175
|
+
persist: bool = False,
|
|
176
|
+
user_info: dict[str, Any] | None = None,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""使用已获得的 Qingflow token 建立本地会话。"""
|
|
179
|
+
normalized_base_url = self._normalize_base_url(base_url)
|
|
180
|
+
normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
|
|
181
|
+
normalized_token = self._normalize_text(token)
|
|
182
|
+
normalized_login_token = self._normalize_text(login_token)
|
|
183
|
+
if not normalized_token:
|
|
184
|
+
raise_tool_error(QingflowApiError.config_error("token is required"))
|
|
185
|
+
|
|
186
|
+
resolved_user_info = user_info if isinstance(user_info, dict) else None
|
|
187
|
+
response_qf_version: str | None = None
|
|
188
|
+
if resolved_user_info is None:
|
|
189
|
+
resolved_user_info, response_qf_version = self._fetch_user_info(
|
|
190
|
+
normalized_base_url,
|
|
191
|
+
normalized_token,
|
|
192
|
+
None,
|
|
193
|
+
qf_version=normalized_qf_version,
|
|
194
|
+
qf_version_source=qf_version_source,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
last_workspace = resolved_user_info.get("lastWsInfo")
|
|
198
|
+
selected_ws_id = self._coerce_positive_int(
|
|
199
|
+
last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
|
|
200
|
+
)
|
|
201
|
+
selected_ws_name = self._normalize_text(
|
|
202
|
+
(last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
|
|
203
|
+
or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
|
|
204
|
+
or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
|
|
205
|
+
)
|
|
206
|
+
workspace_qf_version = (
|
|
207
|
+
self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
|
|
208
|
+
)
|
|
209
|
+
if selected_ws_id is None:
|
|
210
|
+
fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
|
|
211
|
+
normalized_base_url,
|
|
212
|
+
normalized_token,
|
|
213
|
+
qf_version=normalized_qf_version,
|
|
214
|
+
qf_version_source=qf_version_source,
|
|
215
|
+
)
|
|
216
|
+
if isinstance(fallback_workspace, dict):
|
|
217
|
+
selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
|
|
218
|
+
selected_ws_name = self._normalize_text(
|
|
219
|
+
fallback_workspace.get("workspaceName")
|
|
220
|
+
or fallback_workspace.get("wsName")
|
|
221
|
+
or fallback_workspace.get("remark")
|
|
222
|
+
) or selected_ws_name
|
|
223
|
+
workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
|
|
224
|
+
elif selected_ws_name is None or workspace_qf_version is None:
|
|
225
|
+
workspace = self._fetch_workspace_with_name_fallback(
|
|
226
|
+
normalized_base_url,
|
|
227
|
+
normalized_token,
|
|
228
|
+
selected_ws_id,
|
|
229
|
+
qf_version=normalized_qf_version,
|
|
230
|
+
qf_version_source=qf_version_source,
|
|
231
|
+
)
|
|
232
|
+
if isinstance(workspace, dict):
|
|
233
|
+
selected_ws_name = self._normalize_text(
|
|
234
|
+
workspace.get("workspaceName")
|
|
235
|
+
or workspace.get("wsName")
|
|
236
|
+
or workspace.get("remark")
|
|
237
|
+
) or selected_ws_name
|
|
238
|
+
workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
|
|
239
|
+
|
|
240
|
+
if workspace_qf_version is not None:
|
|
241
|
+
resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
|
|
242
|
+
else:
|
|
243
|
+
resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
|
|
244
|
+
response_qf_version,
|
|
245
|
+
fallback_qf_version=normalized_qf_version,
|
|
246
|
+
fallback_source=qf_version_source,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
uid = self._coerce_positive_int(resolved_user_info.get("uid"))
|
|
250
|
+
if uid is None:
|
|
251
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
252
|
+
|
|
253
|
+
session_profile = self.sessions.save_session(
|
|
254
|
+
profile=profile,
|
|
255
|
+
base_url=normalized_base_url,
|
|
256
|
+
qf_version=resolved_qf_version,
|
|
257
|
+
qf_version_source=resolved_qf_version_source,
|
|
258
|
+
token=normalized_token,
|
|
259
|
+
login_token=normalized_login_token,
|
|
260
|
+
credential=None,
|
|
261
|
+
uid=uid,
|
|
262
|
+
email=self._normalize_text(resolved_user_info.get("email")),
|
|
263
|
+
nick_name=self._normalize_text(
|
|
264
|
+
resolved_user_info.get("nickName")
|
|
265
|
+
or resolved_user_info.get("displayName")
|
|
266
|
+
or resolved_user_info.get("name")
|
|
267
|
+
),
|
|
268
|
+
persist=persist,
|
|
269
|
+
)
|
|
270
|
+
if selected_ws_id is not None:
|
|
271
|
+
session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"profile": session_profile.profile,
|
|
275
|
+
"base_url": session_profile.base_url,
|
|
276
|
+
"qf_version": session_profile.qf_version,
|
|
277
|
+
"qf_version_source": session_profile.qf_version_source,
|
|
278
|
+
"uid": session_profile.uid,
|
|
279
|
+
"email": session_profile.email,
|
|
280
|
+
"nick_name": session_profile.nick_name,
|
|
281
|
+
"selected_ws_id": session_profile.selected_ws_id,
|
|
282
|
+
"selected_ws_name": session_profile.selected_ws_name,
|
|
283
|
+
"suggested_ws_id": session_profile.selected_ws_id,
|
|
284
|
+
"suggested_ws_name": session_profile.selected_ws_name,
|
|
285
|
+
"persisted": session_profile.persisted,
|
|
286
|
+
"request_route": self._request_route_payload(
|
|
287
|
+
BackendRequestContext(
|
|
288
|
+
base_url=session_profile.base_url,
|
|
289
|
+
token=normalized_token,
|
|
290
|
+
ws_id=session_profile.selected_ws_id,
|
|
291
|
+
qf_version=session_profile.qf_version,
|
|
292
|
+
qf_version_source=session_profile.qf_version_source,
|
|
293
|
+
)
|
|
294
|
+
),
|
|
295
|
+
}
|
|
296
|
+
|
|
167
297
|
def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
|
|
168
298
|
"""从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
|
|
169
299
|
normalized_base_url = self._normalize_text(base_url)
|
|
@@ -100,7 +100,7 @@ class ToolBase:
|
|
|
100
100
|
self.sessions.invalidate(profile)
|
|
101
101
|
error = QingflowApiError(
|
|
102
102
|
category="auth",
|
|
103
|
-
message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
|
|
103
|
+
message=f"Qingflow session for profile '{profile}' has expired. Run auth login or auth_use_credential again.",
|
|
104
104
|
backend_code=error.backend_code,
|
|
105
105
|
request_id=error.request_id,
|
|
106
106
|
http_status=error.http_status,
|