@qingflow-tech/qingflow-cli 1.1.41 → 1.1.43
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 +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/skills/qingflow-cli/reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/src/qingflow_mcp/auth_provider.py +522 -0
- package/src/qingflow_mcp/cli/commands/auth.py +125 -23
- package/src/qingflow_mcp/cli/context.py +13 -2
- package/src/qingflow_mcp/cli/main.py +9 -1
- package/src/qingflow_mcp/cli/oauth_callback.py +140 -0
- package/src/qingflow_mcp/cli/oauth_client.py +276 -0
- package/src/qingflow_mcp/cloud_auth.py +31 -0
- package/src/qingflow_mcp/cloud_context.py +5 -0
- package/src/qingflow_mcp/cloud_oauth_proxy.py +454 -0
- package/src/qingflow_mcp/cloud_redis.py +25 -0
- package/src/qingflow_mcp/config.py +56 -0
- package/src/qingflow_mcp/oauth_profile_store.py +264 -0
- package/src/qingflow_mcp/oauth_token_store.py +210 -0
- package/src/qingflow_mcp/server_app_user.py +2 -1
- package/src/qingflow_mcp/server_cloud.py +265 -22
- package/src/qingflow_mcp/tools/auth_tools.py +163 -1
- package/src/qingflow_mcp/tools/base.py +43 -3
- package/src/qingflow_mcp/tools/task_context_tools.py +6 -0
|
@@ -10,7 +10,13 @@ from mcp.server.fastmcp import FastMCP
|
|
|
10
10
|
|
|
11
11
|
from .backend_client import BackendClient
|
|
12
12
|
from .cloud_auth import CloudAuthBroker
|
|
13
|
-
from .cloud_context import current_cloud_context
|
|
13
|
+
from .cloud_context import CloudMcpContext, current_call_platform, current_cloud_context
|
|
14
|
+
from .cloud_oauth_proxy import (
|
|
15
|
+
CloudOAuthProxyConfig,
|
|
16
|
+
OAuthAccessTokenResolver,
|
|
17
|
+
OAuthProxyResponse,
|
|
18
|
+
OAuthServiceProxy,
|
|
19
|
+
)
|
|
14
20
|
from .config import get_cloud_http_allowed_hosts, get_cloud_http_host, get_cloud_http_port
|
|
15
21
|
from .errors import QingflowApiError, is_auth_like_error
|
|
16
22
|
from .server_app_builder import build_builder_server
|
|
@@ -18,6 +24,7 @@ from .server_app_user import build_user_server
|
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
BUILDER_TOOL_PREFIX = "builder_"
|
|
27
|
+
MAX_OAUTH_PROXY_BODY_BYTES = 64 * 1024
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
ASGIApp = Callable[
|
|
@@ -36,11 +43,15 @@ class CloudMcpAuthApp:
|
|
|
36
43
|
app: ASGIApp,
|
|
37
44
|
broker: CloudAuthBroker,
|
|
38
45
|
*,
|
|
46
|
+
oauth_proxy: OAuthServiceProxy | None = None,
|
|
47
|
+
oauth_token_resolver: OAuthAccessTokenResolver | None = None,
|
|
39
48
|
allowed_hosts: list[str] | None = None,
|
|
40
49
|
internal_host: str | None = None,
|
|
41
50
|
) -> None:
|
|
42
51
|
self._app = app
|
|
43
52
|
self._broker = broker
|
|
53
|
+
self._oauth_proxy = oauth_proxy
|
|
54
|
+
self._oauth_token_resolver = oauth_token_resolver
|
|
44
55
|
self._allowed_hosts = [
|
|
45
56
|
host.strip().lower()
|
|
46
57
|
for host in (allowed_hosts if allowed_hosts is not None else get_cloud_http_allowed_hosts())
|
|
@@ -69,22 +80,65 @@ class CloudMcpAuthApp:
|
|
|
69
80
|
await self._json_response(send, 200, {"status": "ok"})
|
|
70
81
|
return
|
|
71
82
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
83
|
+
if self._oauth_proxy is not None:
|
|
84
|
+
backend_metadata_path = self._oauth_proxy.config.backend_metadata_path(path)
|
|
85
|
+
if backend_metadata_path is not None:
|
|
86
|
+
await self._proxy_oauth_metadata(
|
|
87
|
+
scope,
|
|
88
|
+
send,
|
|
89
|
+
backend_metadata_path,
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
backend_oauth_path = self._oauth_proxy.config.backend_oauth_endpoint_path(path)
|
|
93
|
+
if backend_oauth_path is not None:
|
|
94
|
+
await self._proxy_oauth_endpoint(
|
|
95
|
+
scope,
|
|
96
|
+
receive,
|
|
97
|
+
send,
|
|
98
|
+
backend_oauth_path,
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
auth_value, explicit_credential, call_platform = self._extract_auth_value(scope)
|
|
103
|
+
if auth_value is None:
|
|
104
|
+
await self._authentication_required(send)
|
|
82
105
|
return
|
|
83
106
|
|
|
107
|
+
use_oauth = (
|
|
108
|
+
not explicit_credential
|
|
109
|
+
and self._oauth_token_resolver is not None
|
|
110
|
+
and not auth_value.startswith("mcp_")
|
|
111
|
+
)
|
|
112
|
+
access_context: dict[str, Any] | None = None
|
|
84
113
|
try:
|
|
85
|
-
|
|
114
|
+
if use_oauth:
|
|
115
|
+
assert self._oauth_token_resolver is not None
|
|
116
|
+
access_context = await anyio.to_thread.run_sync(
|
|
117
|
+
self._broker.resolve_access_context,
|
|
118
|
+
auth_value,
|
|
119
|
+
)
|
|
120
|
+
if access_context is not None:
|
|
121
|
+
assert self._oauth_proxy is not None
|
|
122
|
+
token_resource = str(access_context.get("resource") or "").strip()
|
|
123
|
+
if token_resource != self._oauth_proxy.config.resource_url:
|
|
124
|
+
raise QingflowApiError(
|
|
125
|
+
category="auth",
|
|
126
|
+
message="OAuth access token was issued for a different protected resource",
|
|
127
|
+
)
|
|
128
|
+
cloud_context = await anyio.to_thread.run_sync(
|
|
129
|
+
self._resolve_oauth_or_legacy_credential,
|
|
130
|
+
auth_value,
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
cloud_context = await anyio.to_thread.run_sync(
|
|
134
|
+
self._broker.resolve,
|
|
135
|
+
auth_value,
|
|
136
|
+
)
|
|
86
137
|
except QingflowApiError as error:
|
|
87
138
|
status = 401 if is_auth_like_error(error) else 500
|
|
139
|
+
if status == 401 and use_oauth:
|
|
140
|
+
await self._oauth_authentication_required(send, "invalid_token", error.message)
|
|
141
|
+
return
|
|
88
142
|
await self._json_response(
|
|
89
143
|
send,
|
|
90
144
|
status,
|
|
@@ -97,30 +151,165 @@ class CloudMcpAuthApp:
|
|
|
97
151
|
)
|
|
98
152
|
return
|
|
99
153
|
|
|
100
|
-
|
|
154
|
+
trusted_source = (
|
|
155
|
+
str(access_context.get("source_code") or "").strip()
|
|
156
|
+
if access_context is not None
|
|
157
|
+
else ""
|
|
158
|
+
)
|
|
159
|
+
if trusted_source:
|
|
160
|
+
call_platform = trusted_source
|
|
161
|
+
elif not call_platform:
|
|
162
|
+
call_platform = await anyio.to_thread.run_sync(
|
|
163
|
+
self._broker.resolve_call_platform,
|
|
164
|
+
cloud_context.token,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
context_token = current_cloud_context.set(cloud_context)
|
|
168
|
+
call_platform_token = current_call_platform.set(call_platform)
|
|
101
169
|
try:
|
|
102
170
|
scope = _ensure_streamable_http_accept(scope)
|
|
103
171
|
await self._app(scope, receive, send)
|
|
104
172
|
finally:
|
|
105
|
-
|
|
173
|
+
current_call_platform.reset(call_platform_token)
|
|
174
|
+
current_cloud_context.reset(context_token)
|
|
106
175
|
|
|
107
|
-
def
|
|
176
|
+
def _resolve_oauth_or_legacy_credential(self, auth_value: str) -> CloudMcpContext:
|
|
177
|
+
assert self._oauth_token_resolver is not None
|
|
178
|
+
try:
|
|
179
|
+
return self._oauth_token_resolver.resolve(auth_value)
|
|
180
|
+
except QingflowApiError as oauth_error:
|
|
181
|
+
if not is_auth_like_error(oauth_error):
|
|
182
|
+
raise
|
|
183
|
+
try:
|
|
184
|
+
return self._broker.resolve(auth_value)
|
|
185
|
+
except QingflowApiError:
|
|
186
|
+
raise oauth_error
|
|
187
|
+
|
|
188
|
+
def _extract_auth_value(self, scope: dict[str, Any]) -> tuple[str | None, bool, str]:
|
|
108
189
|
headers = {
|
|
109
190
|
key.decode("latin1").lower(): value.decode("latin1")
|
|
110
191
|
for key, value in scope.get("headers", [])
|
|
111
192
|
}
|
|
193
|
+
call_platform = headers.get("x-qf-callplatform", "").strip()
|
|
112
194
|
authorization = headers.get("authorization", "")
|
|
113
195
|
if authorization.lower().startswith("bearer "):
|
|
114
|
-
|
|
115
|
-
return
|
|
196
|
+
bearer_token = authorization[7:].strip()
|
|
197
|
+
return bearer_token or None, False, call_platform
|
|
116
198
|
credential = headers.get("x-qingflow-client-id", "").strip()
|
|
117
|
-
return credential or None
|
|
199
|
+
return credential or None, True, call_platform
|
|
200
|
+
|
|
201
|
+
async def _authentication_required(
|
|
202
|
+
self,
|
|
203
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
204
|
+
) -> None:
|
|
205
|
+
if self._oauth_proxy is not None:
|
|
206
|
+
await self._oauth_authentication_required(
|
|
207
|
+
send,
|
|
208
|
+
"invalid_token",
|
|
209
|
+
"Authentication required",
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
await self._json_response(
|
|
213
|
+
send,
|
|
214
|
+
401,
|
|
215
|
+
{
|
|
216
|
+
"error": "missing_mcp_credential",
|
|
217
|
+
"message": "Authorization: Bearer <mcp_credential> is required",
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def _oauth_authentication_required(
|
|
222
|
+
self,
|
|
223
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
224
|
+
error: str,
|
|
225
|
+
description: str,
|
|
226
|
+
) -> None:
|
|
227
|
+
assert self._oauth_proxy is not None
|
|
228
|
+
challenge = (
|
|
229
|
+
'Bearer realm="OAuth", '
|
|
230
|
+
f'error="{_escape_auth_param(error)}", '
|
|
231
|
+
f'error_description="{_escape_auth_param(description)}", '
|
|
232
|
+
f'resource_metadata="{self._oauth_proxy.config.resource_metadata_url}"'
|
|
233
|
+
)
|
|
234
|
+
await self._json_response(
|
|
235
|
+
send,
|
|
236
|
+
401,
|
|
237
|
+
{"error": error, "error_description": description},
|
|
238
|
+
extra_headers=[
|
|
239
|
+
(b"www-authenticate", challenge.encode("latin1", errors="replace")),
|
|
240
|
+
(b"cache-control", b"no-store"),
|
|
241
|
+
(b"pragma", b"no-cache"),
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def _proxy_oauth_metadata(
|
|
246
|
+
self,
|
|
247
|
+
scope: dict[str, Any],
|
|
248
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
249
|
+
backend_path: str,
|
|
250
|
+
) -> None:
|
|
251
|
+
assert self._oauth_proxy is not None
|
|
252
|
+
try:
|
|
253
|
+
response = await anyio.to_thread.run_sync(
|
|
254
|
+
self._oauth_proxy.request_metadata,
|
|
255
|
+
str(scope.get("method") or "GET"),
|
|
256
|
+
backend_path,
|
|
257
|
+
bytes(scope.get("query_string") or b""),
|
|
258
|
+
)
|
|
259
|
+
except QingflowApiError as error:
|
|
260
|
+
await self._json_response(
|
|
261
|
+
send,
|
|
262
|
+
502,
|
|
263
|
+
{"error": "oauth_service_unavailable", "message": error.message},
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
await self._raw_response(send, response)
|
|
267
|
+
|
|
268
|
+
async def _proxy_oauth_endpoint(
|
|
269
|
+
self,
|
|
270
|
+
scope: dict[str, Any],
|
|
271
|
+
receive: Callable[[], Awaitable[dict[str, Any]]],
|
|
272
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
273
|
+
backend_path: str,
|
|
274
|
+
) -> None:
|
|
275
|
+
assert self._oauth_proxy is not None
|
|
276
|
+
try:
|
|
277
|
+
body = await _read_request_body(receive, MAX_OAUTH_PROXY_BODY_BYTES)
|
|
278
|
+
except ValueError:
|
|
279
|
+
await self._json_response(
|
|
280
|
+
send,
|
|
281
|
+
413,
|
|
282
|
+
{
|
|
283
|
+
"error": "invalid_request",
|
|
284
|
+
"error_description": "OAuth request body is too large",
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
try:
|
|
289
|
+
response = await anyio.to_thread.run_sync(
|
|
290
|
+
self._oauth_proxy.request_oauth_endpoint,
|
|
291
|
+
str(scope.get("method") or "GET"),
|
|
292
|
+
backend_path,
|
|
293
|
+
bytes(scope.get("query_string") or b""),
|
|
294
|
+
body,
|
|
295
|
+
tuple(scope.get("headers") or ()),
|
|
296
|
+
)
|
|
297
|
+
except QingflowApiError as error:
|
|
298
|
+
await self._json_response(
|
|
299
|
+
send,
|
|
300
|
+
502,
|
|
301
|
+
{"error": "oauth_service_unavailable", "message": error.message},
|
|
302
|
+
)
|
|
303
|
+
return
|
|
304
|
+
await self._raw_response(send, response)
|
|
118
305
|
|
|
119
306
|
async def _json_response(
|
|
120
307
|
self,
|
|
121
308
|
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
122
309
|
status: int,
|
|
123
310
|
payload: dict[str, Any],
|
|
311
|
+
*,
|
|
312
|
+
extra_headers: list[tuple[bytes, bytes]] | None = None,
|
|
124
313
|
) -> None:
|
|
125
314
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
126
315
|
await send(
|
|
@@ -130,11 +319,26 @@ class CloudMcpAuthApp:
|
|
|
130
319
|
"headers": [
|
|
131
320
|
(b"content-type", b"application/json; charset=utf-8"),
|
|
132
321
|
(b"content-length", str(len(body)).encode("ascii")),
|
|
133
|
-
],
|
|
322
|
+
] + list(extra_headers or []),
|
|
134
323
|
}
|
|
135
324
|
)
|
|
136
325
|
await send({"type": "http.response.body", "body": body})
|
|
137
326
|
|
|
327
|
+
async def _raw_response(
|
|
328
|
+
self,
|
|
329
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
330
|
+
response: OAuthProxyResponse,
|
|
331
|
+
) -> None:
|
|
332
|
+
await send(
|
|
333
|
+
{
|
|
334
|
+
"type": "http.response.start",
|
|
335
|
+
"status": response.status_code,
|
|
336
|
+
"headers": list(response.headers)
|
|
337
|
+
+ [(b"content-length", str(len(response.body)).encode("ascii"))],
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
await send({"type": "http.response.body", "body": response.body})
|
|
341
|
+
|
|
138
342
|
async def _plain_response(
|
|
139
343
|
self,
|
|
140
344
|
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
@@ -176,12 +380,25 @@ def build_cloud_user_app() -> CloudMcpAuthApp:
|
|
|
176
380
|
|
|
177
381
|
def build_cloud_app() -> CloudMcpAuthApp:
|
|
178
382
|
server = build_combined_server()
|
|
179
|
-
|
|
180
|
-
|
|
383
|
+
backend = BackendClient()
|
|
384
|
+
broker = CloudAuthBroker.from_config(backend=backend)
|
|
385
|
+
oauth_config = CloudOAuthProxyConfig.from_config()
|
|
386
|
+
oauth_proxy = OAuthServiceProxy(oauth_config) if oauth_config is not None else None
|
|
387
|
+
oauth_token_resolver = (
|
|
388
|
+
OAuthAccessTokenResolver.from_config(backend)
|
|
389
|
+
if oauth_config is not None
|
|
390
|
+
else None
|
|
391
|
+
)
|
|
392
|
+
return CloudMcpAuthApp(
|
|
393
|
+
server.streamable_http_app(),
|
|
394
|
+
broker,
|
|
395
|
+
oauth_proxy=oauth_proxy,
|
|
396
|
+
oauth_token_resolver=oauth_token_resolver,
|
|
397
|
+
)
|
|
181
398
|
|
|
182
399
|
|
|
183
400
|
def build_combined_server() -> FastMCP:
|
|
184
|
-
server = build_user_server()
|
|
401
|
+
server = build_user_server(stateless_http=True)
|
|
185
402
|
builder_server = build_builder_server()
|
|
186
403
|
server._mcp_server.name = "Qingflow Combined MCP"
|
|
187
404
|
_register_prefixed_builder_tools(server, builder_server)
|
|
@@ -237,6 +454,22 @@ def _extract_host(scope: dict[str, Any]) -> str:
|
|
|
237
454
|
return ""
|
|
238
455
|
|
|
239
456
|
|
|
457
|
+
async def _read_request_body(
|
|
458
|
+
receive: Callable[[], Awaitable[dict[str, Any]]],
|
|
459
|
+
limit: int,
|
|
460
|
+
) -> bytes:
|
|
461
|
+
body = bytearray()
|
|
462
|
+
while True:
|
|
463
|
+
message = await receive()
|
|
464
|
+
if message.get("type") != "http.request":
|
|
465
|
+
return bytes(body)
|
|
466
|
+
body.extend(bytes(message.get("body") or b""))
|
|
467
|
+
if len(body) > limit:
|
|
468
|
+
raise ValueError("request body is too large")
|
|
469
|
+
if not message.get("more_body", False):
|
|
470
|
+
return bytes(body)
|
|
471
|
+
|
|
472
|
+
|
|
240
473
|
def _is_allowed_host(host: str, allowed_hosts: list[str]) -> bool:
|
|
241
474
|
normalized_host = host.strip().lower().rstrip(".")
|
|
242
475
|
host_without_port = _strip_port(normalized_host)
|
|
@@ -308,6 +541,16 @@ def _ensure_streamable_http_accept(scope: dict[str, Any]) -> dict[str, Any]:
|
|
|
308
541
|
return rewritten
|
|
309
542
|
|
|
310
543
|
|
|
544
|
+
def _escape_auth_param(value: str) -> str:
|
|
545
|
+
return (
|
|
546
|
+
str(value or "")
|
|
547
|
+
.replace("\\", "\\\\")
|
|
548
|
+
.replace('"', '\\"')
|
|
549
|
+
.replace("\r", " ")
|
|
550
|
+
.replace("\n", " ")
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
311
554
|
app = LazyCloudApp()
|
|
312
555
|
|
|
313
556
|
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from mcp.server.fastmcp import FastMCP
|
|
8
8
|
|
|
9
|
+
from ..auth_provider import CliSessionFacade
|
|
9
10
|
from ..backend_client import BackendRequestContext, BackendResponse
|
|
10
11
|
from ..cloud_context import current_cloud_context
|
|
11
12
|
from ..config import (
|
|
@@ -16,6 +17,7 @@ from ..config import (
|
|
|
16
17
|
normalize_base_url,
|
|
17
18
|
)
|
|
18
19
|
from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
|
|
20
|
+
from ..oauth_token_store import OAuthTokenBundle
|
|
19
21
|
from ..session_store import SessionStore
|
|
20
22
|
from .base import ToolBase
|
|
21
23
|
|
|
@@ -29,7 +31,7 @@ class AuthTools(ToolBase):
|
|
|
29
31
|
3. 退出并清理当前 profile 会话。
|
|
30
32
|
"""
|
|
31
33
|
|
|
32
|
-
def __init__(self, sessions: SessionStore, backend) -> None:
|
|
34
|
+
def __init__(self, sessions: SessionStore | CliSessionFacade, backend) -> None:
|
|
33
35
|
"""执行内部辅助逻辑。"""
|
|
34
36
|
super().__init__(sessions, backend)
|
|
35
37
|
self.sessions.set_profile_refresher(self._refresh_profile_via_credential)
|
|
@@ -307,6 +309,130 @@ class AuthTools(ToolBase):
|
|
|
307
309
|
),
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
def auth_use_oauth_token(
|
|
313
|
+
self,
|
|
314
|
+
*,
|
|
315
|
+
profile: str,
|
|
316
|
+
base_url: str,
|
|
317
|
+
qf_version: str | None,
|
|
318
|
+
issuer: str,
|
|
319
|
+
resource: str,
|
|
320
|
+
bundle: OAuthTokenBundle,
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
"""使用 OAuth Token Bundle 建立 CLI 专用会话。"""
|
|
323
|
+
if not isinstance(self.sessions, CliSessionFacade):
|
|
324
|
+
raise_tool_error(QingflowApiError.config_error("OAuth login is only available in the Qingflow CLI."))
|
|
325
|
+
normalized_base_url = self._normalize_base_url(base_url)
|
|
326
|
+
normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
|
|
327
|
+
resolved_user_info, response_qf_version = self._fetch_user_info(
|
|
328
|
+
normalized_base_url,
|
|
329
|
+
bundle.access_token,
|
|
330
|
+
None,
|
|
331
|
+
qf_version=normalized_qf_version,
|
|
332
|
+
qf_version_source=qf_version_source,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
last_workspace = resolved_user_info.get("lastWsInfo")
|
|
336
|
+
selected_ws_id = self._coerce_positive_int(
|
|
337
|
+
last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
|
|
338
|
+
)
|
|
339
|
+
selected_ws_name = self._normalize_text(
|
|
340
|
+
(last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
|
|
341
|
+
or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
|
|
342
|
+
or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
|
|
343
|
+
)
|
|
344
|
+
workspace_qf_version = (
|
|
345
|
+
self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
|
|
346
|
+
)
|
|
347
|
+
if selected_ws_id is None:
|
|
348
|
+
fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
|
|
349
|
+
normalized_base_url,
|
|
350
|
+
bundle.access_token,
|
|
351
|
+
qf_version=normalized_qf_version,
|
|
352
|
+
qf_version_source=qf_version_source,
|
|
353
|
+
)
|
|
354
|
+
if isinstance(fallback_workspace, dict):
|
|
355
|
+
selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
|
|
356
|
+
selected_ws_name = self._normalize_text(
|
|
357
|
+
fallback_workspace.get("workspaceName")
|
|
358
|
+
or fallback_workspace.get("wsName")
|
|
359
|
+
or fallback_workspace.get("remark")
|
|
360
|
+
) or selected_ws_name
|
|
361
|
+
workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
|
|
362
|
+
elif selected_ws_name is None or workspace_qf_version is None:
|
|
363
|
+
workspace = self._fetch_workspace_with_name_fallback(
|
|
364
|
+
normalized_base_url,
|
|
365
|
+
bundle.access_token,
|
|
366
|
+
selected_ws_id,
|
|
367
|
+
qf_version=normalized_qf_version,
|
|
368
|
+
qf_version_source=qf_version_source,
|
|
369
|
+
)
|
|
370
|
+
if isinstance(workspace, dict):
|
|
371
|
+
selected_ws_name = self._normalize_text(
|
|
372
|
+
workspace.get("workspaceName")
|
|
373
|
+
or workspace.get("wsName")
|
|
374
|
+
or workspace.get("remark")
|
|
375
|
+
) or selected_ws_name
|
|
376
|
+
workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
|
|
377
|
+
|
|
378
|
+
if workspace_qf_version is not None:
|
|
379
|
+
resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
|
|
380
|
+
else:
|
|
381
|
+
resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
|
|
382
|
+
response_qf_version,
|
|
383
|
+
fallback_qf_version=normalized_qf_version,
|
|
384
|
+
fallback_source=qf_version_source,
|
|
385
|
+
)
|
|
386
|
+
uid = self._coerce_positive_int(resolved_user_info.get("uid"))
|
|
387
|
+
if uid is None:
|
|
388
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
389
|
+
|
|
390
|
+
state = self.sessions.activate_oauth(
|
|
391
|
+
profile=profile,
|
|
392
|
+
base_url=normalized_base_url,
|
|
393
|
+
qf_version=resolved_qf_version,
|
|
394
|
+
qf_version_source=resolved_qf_version_source,
|
|
395
|
+
uid=uid,
|
|
396
|
+
email=self._normalize_text(resolved_user_info.get("email")),
|
|
397
|
+
nick_name=self._normalize_text(
|
|
398
|
+
resolved_user_info.get("nickName")
|
|
399
|
+
or resolved_user_info.get("displayName")
|
|
400
|
+
or resolved_user_info.get("name")
|
|
401
|
+
),
|
|
402
|
+
selected_ws_id=selected_ws_id,
|
|
403
|
+
selected_ws_name=selected_ws_name,
|
|
404
|
+
issuer=issuer,
|
|
405
|
+
resource=resource,
|
|
406
|
+
bundle=bundle,
|
|
407
|
+
)
|
|
408
|
+
return {
|
|
409
|
+
"profile": state.profile,
|
|
410
|
+
"base_url": state.base_url,
|
|
411
|
+
"qf_version": state.qf_version,
|
|
412
|
+
"qf_version_source": state.qf_version_source,
|
|
413
|
+
"uid": state.uid,
|
|
414
|
+
"email": state.email,
|
|
415
|
+
"nick_name": state.nick_name,
|
|
416
|
+
"selected_ws_id": state.selected_ws_id,
|
|
417
|
+
"selected_ws_name": state.selected_ws_name,
|
|
418
|
+
"suggested_ws_id": state.selected_ws_id,
|
|
419
|
+
"suggested_ws_name": state.selected_ws_name,
|
|
420
|
+
"persisted": True,
|
|
421
|
+
"active_auth_type": "oauth",
|
|
422
|
+
"auth_source": "oauth_pkce",
|
|
423
|
+
"source_code": state.source_code,
|
|
424
|
+
"access_expires_at": state.access_expires_at,
|
|
425
|
+
"request_route": self._request_route_payload(
|
|
426
|
+
BackendRequestContext(
|
|
427
|
+
base_url=state.base_url or normalized_base_url,
|
|
428
|
+
token=bundle.access_token,
|
|
429
|
+
ws_id=state.selected_ws_id,
|
|
430
|
+
qf_version=state.qf_version,
|
|
431
|
+
qf_version_source=state.qf_version_source,
|
|
432
|
+
)
|
|
433
|
+
),
|
|
434
|
+
}
|
|
435
|
+
|
|
310
436
|
def _resolve_auth_inputs(
|
|
311
437
|
self,
|
|
312
438
|
*,
|
|
@@ -380,6 +506,14 @@ class AuthTools(ToolBase):
|
|
|
380
506
|
|
|
381
507
|
def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
|
|
382
508
|
"""执行认证与会话相关逻辑。"""
|
|
509
|
+
if isinstance(self.sessions, CliSessionFacade) and self.sessions.oauth_state(profile) == "none":
|
|
510
|
+
return {
|
|
511
|
+
"profile": profile,
|
|
512
|
+
"active_auth_type": "none",
|
|
513
|
+
"auth_source": "none",
|
|
514
|
+
"persisted": False,
|
|
515
|
+
}
|
|
516
|
+
|
|
383
517
|
def build_response(
|
|
384
518
|
session_profile, # type: ignore[no-untyped-def]
|
|
385
519
|
backend_session, # type: ignore[no-untyped-def]
|
|
@@ -439,6 +573,13 @@ class AuthTools(ToolBase):
|
|
|
439
573
|
"persisted": session_profile.persisted,
|
|
440
574
|
"request_route": self._request_route_payload(context),
|
|
441
575
|
}
|
|
576
|
+
if isinstance(self.sessions, CliSessionFacade):
|
|
577
|
+
active_auth_type = self.sessions.active_auth_type(profile)
|
|
578
|
+
response["active_auth_type"] = active_auth_type
|
|
579
|
+
if active_auth_type == "oauth":
|
|
580
|
+
response.update(self.sessions.oauth_details(profile))
|
|
581
|
+
else:
|
|
582
|
+
response["auth_source"] = active_auth_type
|
|
442
583
|
member_info, member_warnings = self._workspace_member_info(
|
|
443
584
|
session_profile=session_profile,
|
|
444
585
|
backend_session=backend_session,
|
|
@@ -468,8 +609,18 @@ class AuthTools(ToolBase):
|
|
|
468
609
|
return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
|
|
469
610
|
except QingflowApiError as refreshed_error:
|
|
470
611
|
self._handle_error(profile, refreshed_error)
|
|
612
|
+
if error.looks_like_invalid_token() and self._refresh_oauth_session(profile):
|
|
613
|
+
try:
|
|
614
|
+
refreshed_profile, refreshed_backend_session, refreshed_context = self._require_context(
|
|
615
|
+
profile,
|
|
616
|
+
require_workspace=False,
|
|
617
|
+
)
|
|
618
|
+
return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
|
|
619
|
+
except QingflowApiError as refreshed_error:
|
|
620
|
+
self._handle_error(profile, refreshed_error)
|
|
471
621
|
if (
|
|
472
622
|
error.looks_like_invalid_token()
|
|
623
|
+
and not self._uses_oauth(profile)
|
|
473
624
|
and session_profile is not None
|
|
474
625
|
and backend_session is not None
|
|
475
626
|
and self._refresh_session_from_credential(
|
|
@@ -511,6 +662,17 @@ class AuthTools(ToolBase):
|
|
|
511
662
|
|
|
512
663
|
def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
|
|
513
664
|
"""执行认证与会话相关逻辑。"""
|
|
665
|
+
if isinstance(self.sessions, CliSessionFacade) and self.sessions.uses_oauth(profile):
|
|
666
|
+
warnings = self.sessions.oauth_logout(profile)
|
|
667
|
+
result: dict[str, Any] = {
|
|
668
|
+
"profile": profile,
|
|
669
|
+
"logged_out": True,
|
|
670
|
+
"forgot_persisted": True,
|
|
671
|
+
"active_auth_type": "none",
|
|
672
|
+
}
|
|
673
|
+
if warnings:
|
|
674
|
+
result["warnings"] = warnings
|
|
675
|
+
return result
|
|
514
676
|
if not self.sessions.has_profile(profile):
|
|
515
677
|
raise_tool_error(QingflowApiError.auth_required(profile))
|
|
516
678
|
self.sessions.logout(profile, forget_persisted=forget_persisted)
|