@josephyan/qingflow-app-user-mcp 0.2.0-beta.991 → 0.2.0-beta.993

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 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.991
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.993
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.991 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.993 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.991",
3
+ "version": "0.2.0-beta.993",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b991"
7
+ version = "0.2.0b993"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b991"
8
+ _FALLBACK_VERSION = "0.2.0b993"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -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:默认轻流账号密码交互;--browser 使用 OAuth")
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
- 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
- }
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"] = {key: value for key, value in login_summary.items() if value}
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, or use --browser."
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, --password-stdin, or --browser."
88
+ "qingflow auth login needs an interactive terminal or --password-stdin."
154
89
  )
155
90
 
156
91
 
@@ -269,6 +269,15 @@ class AuthTools(ToolBase):
269
269
  )
270
270
  if selected_ws_id is not None:
271
271
  session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
272
+ backend_session = self.sessions.get_backend_session(profile)
273
+ permission_level = (
274
+ self._workspace_permission_level(
275
+ session_profile=session_profile,
276
+ backend_session=backend_session,
277
+ )
278
+ if backend_session is not None
279
+ else None
280
+ )
272
281
 
273
282
  return {
274
283
  "profile": session_profile.profile,
@@ -282,6 +291,7 @@ class AuthTools(ToolBase):
282
291
  "selected_ws_name": session_profile.selected_ws_name,
283
292
  "suggested_ws_id": session_profile.selected_ws_id,
284
293
  "suggested_ws_name": session_profile.selected_ws_name,
294
+ "permission_level": permission_level,
285
295
  "persisted": session_profile.persisted,
286
296
  "request_route": self._request_route_payload(
287
297
  BackendRequestContext(
@@ -781,6 +791,13 @@ class AuthTools(ToolBase):
781
791
  if ws_id is None:
782
792
  return default_payload, []
783
793
 
794
+ permission_level = self._workspace_permission_level(
795
+ session_profile=session_profile,
796
+ backend_session=backend_session,
797
+ )
798
+ payload = dict(default_payload)
799
+ payload["permission_level"] = permission_level
800
+
784
801
  context = BackendRequestContext(
785
802
  base_url=backend_session.base_url,
786
803
  token=backend_session.token,
@@ -788,12 +805,6 @@ class AuthTools(ToolBase):
788
805
  qf_version=backend_session.qf_version,
789
806
  qf_version_source=backend_session.qf_version_source,
790
807
  )
791
- permission_level = self._resolve_permission_level(
792
- self._workspace_auth(context, ws_id=ws_id)
793
- )
794
- payload = dict(default_payload)
795
- payload["permission_level"] = permission_level
796
-
797
808
  member = self._lookup_current_member(
798
809
  context=context,
799
810
  uid=session_profile.uid,
@@ -815,6 +826,25 @@ class AuthTools(ToolBase):
815
826
  payload["roles"] = self._compact_roles(member)
816
827
  return payload, []
817
828
 
829
+ def _workspace_permission_level(
830
+ self,
831
+ *,
832
+ session_profile, # type: ignore[no-untyped-def]
833
+ backend_session, # type: ignore[no-untyped-def]
834
+ ) -> str | None:
835
+ """Resolve the selected workspace permission label without requiring member lookup."""
836
+ ws_id = session_profile.selected_ws_id
837
+ if ws_id is None:
838
+ return None
839
+ context = BackendRequestContext(
840
+ base_url=backend_session.base_url,
841
+ token=backend_session.token,
842
+ ws_id=ws_id,
843
+ qf_version=backend_session.qf_version,
844
+ qf_version_source=backend_session.qf_version_source,
845
+ )
846
+ return self._resolve_permission_level(self._workspace_auth(context, ws_id=ws_id))
847
+
818
848
  def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
819
849
  """执行内部辅助逻辑。"""
820
850
  workspace = self._fetch_workspace_auth_from_detail(context, ws_id=ws_id)
@@ -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