@qingflow-tech/qingflow-cli 1.1.43-beta.5 → 1.1.44-beta.1
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/builder/30-schema-fields.md +4 -0
- package/src/qingflow_mcp/auth_provider.py +522 -0
- package/src/qingflow_mcp/builder_facade/app_form.py +254 -50
- 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 +20 -0
- package/src/qingflow_mcp/cloud_oauth_proxy.py +1 -0
- package/src/qingflow_mcp/config.py +12 -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_cloud.py +21 -1
- package/src/qingflow_mcp/tools/auth_tools.py +163 -1
- package/src/qingflow_mcp/tools/base.py +41 -2
|
@@ -3,9 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
import getpass
|
|
5
5
|
import sys
|
|
6
|
+
import webbrowser
|
|
6
7
|
|
|
8
|
+
from ...config import get_cli_oauth_resource_url, get_default_base_url, normalize_base_url
|
|
7
9
|
from ...errors import QingflowApiError
|
|
8
10
|
from ..context import CliContext
|
|
11
|
+
from ..oauth_callback import OAuthCallbackServer
|
|
12
|
+
from ..oauth_client import PKCEAuthorization
|
|
9
13
|
from ..qingflow_login import login_with_qingflow_password
|
|
10
14
|
from .common import read_secret_arg
|
|
11
15
|
|
|
@@ -15,11 +19,13 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
15
19
|
auth_subparsers = parser.add_subparsers(dest="auth_command", required=True)
|
|
16
20
|
|
|
17
21
|
login = auth_subparsers.add_parser("login", help="登录 CLI:轻流账号密码交互")
|
|
22
|
+
login.add_argument("--method", choices=("password", "oauth"), default="password")
|
|
18
23
|
login.add_argument("--base-url")
|
|
19
24
|
login.add_argument("--qf-version")
|
|
20
25
|
login.add_argument("--email", help="轻流账号邮箱;不传时在交互终端提示输入")
|
|
21
26
|
login.add_argument("--password", help="轻流账号密码;建议仅用于本地调试,脚本请优先使用 --password-stdin")
|
|
22
27
|
login.add_argument("--password-stdin", action="store_true", help="从标准输入读取轻流账号密码")
|
|
28
|
+
login.add_argument("--no-browser", action="store_true", help="不自动打开 OAuth 授权页")
|
|
23
29
|
login.add_argument("--persist", action=argparse.BooleanOptionalAction, default=True)
|
|
24
30
|
login.set_defaults(handler=_handle_login, format_hint="auth_whoami")
|
|
25
31
|
|
|
@@ -40,30 +46,94 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
40
46
|
|
|
41
47
|
|
|
42
48
|
def _handle_login(args: argparse.Namespace, context: CliContext) -> dict:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
if args.method == "oauth":
|
|
50
|
+
return _handle_oauth_login(args, context)
|
|
51
|
+
if bool(args.no_browser):
|
|
52
|
+
raise QingflowApiError.config_error("--no-browser is only valid with --method oauth.")
|
|
53
|
+
_begin_legacy_login(context, args.profile)
|
|
54
|
+
try:
|
|
55
|
+
email = _resolve_login_email(args)
|
|
56
|
+
password = _resolve_login_password(args)
|
|
57
|
+
login_result = login_with_qingflow_password(
|
|
58
|
+
base_url=args.base_url,
|
|
59
|
+
email=email,
|
|
60
|
+
password=password,
|
|
61
|
+
)
|
|
62
|
+
result = context.auth.auth_use_token(
|
|
63
|
+
profile=args.profile,
|
|
64
|
+
base_url=args.base_url,
|
|
65
|
+
qf_version=args.qf_version,
|
|
66
|
+
token=login_result.token,
|
|
67
|
+
login_token=login_result.login_token,
|
|
68
|
+
user_info=login_result.user_info,
|
|
69
|
+
persist=bool(args.persist),
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
_cancel_legacy_login(context, args.profile)
|
|
73
|
+
raise
|
|
74
|
+
switch_warnings = _complete_legacy_login(context, args.profile)
|
|
59
75
|
warnings = list(result.get("warnings") or []) if isinstance(result, dict) else []
|
|
76
|
+
warnings.extend(switch_warnings)
|
|
60
77
|
if isinstance(result, dict):
|
|
61
78
|
result["cli_auth"] = {"flow": login_result.flow}
|
|
79
|
+
result["active_auth_type"] = "legacy_token"
|
|
80
|
+
result["auth_source"] = "legacy_token"
|
|
62
81
|
if warnings:
|
|
63
82
|
result["warnings"] = warnings
|
|
64
83
|
return result
|
|
65
84
|
|
|
66
85
|
|
|
86
|
+
def _handle_oauth_login(args: argparse.Namespace, context: CliContext) -> dict:
|
|
87
|
+
if args.email or args.password or bool(args.password_stdin):
|
|
88
|
+
raise QingflowApiError.config_error(
|
|
89
|
+
"--email, --password, and --password-stdin cannot be used with --method oauth."
|
|
90
|
+
)
|
|
91
|
+
if not bool(args.persist):
|
|
92
|
+
raise QingflowApiError.config_error("OAuth login must persist its refresh token; --no-persist is not supported.")
|
|
93
|
+
base_url = normalize_base_url(args.base_url) or get_default_base_url()
|
|
94
|
+
if not base_url:
|
|
95
|
+
raise QingflowApiError.config_error(
|
|
96
|
+
"base_url is required or configure default_base_url / QINGFLOW_MCP_DEFAULT_BASE_URL."
|
|
97
|
+
)
|
|
98
|
+
oauth_client = context.sessions.oauth.oauth_client
|
|
99
|
+
metadata = oauth_client.discover(base_url)
|
|
100
|
+
resource = get_cli_oauth_resource_url()
|
|
101
|
+
pkce = PKCEAuthorization.create()
|
|
102
|
+
callback = OAuthCallbackServer(pkce.state)
|
|
103
|
+
callback.start()
|
|
104
|
+
authorization_url = oauth_client.authorization_url(
|
|
105
|
+
metadata,
|
|
106
|
+
redirect_uri=callback.redirect_uri,
|
|
107
|
+
resource=resource,
|
|
108
|
+
pkce=pkce,
|
|
109
|
+
)
|
|
110
|
+
stream = getattr(args, "_stderr_stream", sys.stderr)
|
|
111
|
+
if bool(args.no_browser):
|
|
112
|
+
stream.write(f"Open this URL to authorize Qingflow CLI:\n{authorization_url}\n")
|
|
113
|
+
elif not webbrowser.open(authorization_url, new=1, autoraise=True):
|
|
114
|
+
stream.write(f"Could not open a browser. Open this URL to authorize Qingflow CLI:\n{authorization_url}\n")
|
|
115
|
+
try:
|
|
116
|
+
callback_result = callback.wait(timeout_seconds=300.0)
|
|
117
|
+
bundle = oauth_client.exchange_code(
|
|
118
|
+
metadata,
|
|
119
|
+
code=callback_result.code,
|
|
120
|
+
redirect_uri=callback.redirect_uri,
|
|
121
|
+
verifier=pkce.verifier,
|
|
122
|
+
)
|
|
123
|
+
result = context.auth.auth_use_oauth_token(
|
|
124
|
+
profile=args.profile,
|
|
125
|
+
base_url=base_url,
|
|
126
|
+
qf_version=args.qf_version,
|
|
127
|
+
issuer=metadata.issuer,
|
|
128
|
+
resource=resource,
|
|
129
|
+
bundle=bundle,
|
|
130
|
+
)
|
|
131
|
+
finally:
|
|
132
|
+
callback.close()
|
|
133
|
+
result["cli_auth"] = {"flow": "oauth_pkce"}
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
67
137
|
def _resolve_login_email(args: argparse.Namespace) -> str:
|
|
68
138
|
email = str(args.email or "").strip()
|
|
69
139
|
if email:
|
|
@@ -95,13 +165,25 @@ def _handle_use_credential(args: argparse.Namespace, context: CliContext) -> dic
|
|
|
95
165
|
if args.credential or bool(args.credential_stdin)
|
|
96
166
|
else ""
|
|
97
167
|
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
168
|
+
_begin_legacy_login(context, args.profile)
|
|
169
|
+
try:
|
|
170
|
+
result = context.auth.auth_use_credential(
|
|
171
|
+
profile=args.profile,
|
|
172
|
+
base_url=args.base_url,
|
|
173
|
+
qf_version=args.qf_version,
|
|
174
|
+
credential=credential,
|
|
175
|
+
persist=bool(args.persist),
|
|
176
|
+
)
|
|
177
|
+
except Exception:
|
|
178
|
+
_cancel_legacy_login(context, args.profile)
|
|
179
|
+
raise
|
|
180
|
+
warnings = list(result.get("warnings") or []) if isinstance(result, dict) else []
|
|
181
|
+
warnings.extend(_complete_legacy_login(context, args.profile))
|
|
182
|
+
result["active_auth_type"] = "credential"
|
|
183
|
+
result["auth_source"] = "credential"
|
|
184
|
+
if warnings:
|
|
185
|
+
result["warnings"] = warnings
|
|
186
|
+
return result
|
|
105
187
|
|
|
106
188
|
|
|
107
189
|
def _handle_whoami(args: argparse.Namespace, context: CliContext) -> dict:
|
|
@@ -110,3 +192,23 @@ def _handle_whoami(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
110
192
|
|
|
111
193
|
def _handle_logout(args: argparse.Namespace, context: CliContext) -> dict:
|
|
112
194
|
return context.auth.auth_logout(profile=args.profile, forget_persisted=bool(args.forget_persisted))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _begin_legacy_login(context: CliContext, profile: str) -> None:
|
|
198
|
+
method = getattr(context.sessions, "begin_legacy_login", None)
|
|
199
|
+
if callable(method):
|
|
200
|
+
method(profile)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _cancel_legacy_login(context: CliContext, profile: str) -> None:
|
|
204
|
+
method = getattr(context.sessions, "cancel_legacy_login", None)
|
|
205
|
+
if callable(method):
|
|
206
|
+
method(profile)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _complete_legacy_login(context: CliContext, profile: str) -> list[dict[str, str]]:
|
|
210
|
+
method = getattr(context.sessions, "complete_legacy_login", None)
|
|
211
|
+
if not callable(method):
|
|
212
|
+
return []
|
|
213
|
+
warnings = method(profile)
|
|
214
|
+
return warnings if isinstance(warnings, list) else []
|
|
@@ -2,7 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
|
+
from ..auth_provider import CliSessionFacade, OAuthSessionProvider
|
|
5
6
|
from ..backend_client import BackendClient
|
|
7
|
+
from ..oauth_profile_store import OAuthProfileStore
|
|
8
|
+
from ..oauth_token_store import OAuthTokenStore
|
|
6
9
|
from ..session_store import SessionStore
|
|
7
10
|
from ..tools.ai_builder_tools import AiBuilderTools
|
|
8
11
|
from ..tools.app_tools import AppTools
|
|
@@ -16,11 +19,12 @@ from ..tools.record_tools import RecordTools
|
|
|
16
19
|
from ..tools.resource_read_tools import ResourceReadTools
|
|
17
20
|
from ..tools.task_context_tools import TaskContextTools
|
|
18
21
|
from ..tools.workspace_tools import WorkspaceTools
|
|
22
|
+
from .oauth_client import OAuthClient
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
@dataclass(slots=True)
|
|
22
26
|
class CliContext:
|
|
23
|
-
sessions:
|
|
27
|
+
sessions: CliSessionFacade
|
|
24
28
|
backend: BackendClient
|
|
25
29
|
auth: AuthTools
|
|
26
30
|
workspace: WorkspaceTools
|
|
@@ -36,11 +40,18 @@ class CliContext:
|
|
|
36
40
|
builder: AiBuilderTools
|
|
37
41
|
|
|
38
42
|
def close(self) -> None:
|
|
43
|
+
self.sessions.close()
|
|
39
44
|
self.backend.close()
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
def build_cli_context() -> CliContext:
|
|
43
|
-
|
|
48
|
+
legacy_sessions = SessionStore()
|
|
49
|
+
oauth_profiles = OAuthProfileStore()
|
|
50
|
+
oauth_tokens = OAuthTokenStore(oauth_profiles)
|
|
51
|
+
sessions = CliSessionFacade(
|
|
52
|
+
legacy_sessions,
|
|
53
|
+
OAuthSessionProvider(oauth_profiles, oauth_tokens, OAuthClient()),
|
|
54
|
+
)
|
|
44
55
|
backend = BackendClient()
|
|
45
56
|
return CliContext(
|
|
46
57
|
sessions=sessions,
|
|
@@ -458,6 +458,13 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
|
|
|
458
458
|
session_profile = None
|
|
459
459
|
workspace_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
|
|
460
460
|
workspace_name = getattr(session_profile, "selected_ws_name", None) if session_profile is not None else None
|
|
461
|
+
active_auth_type = None
|
|
462
|
+
auth_type_resolver = getattr(sessions, "active_auth_type", None)
|
|
463
|
+
if callable(auth_type_resolver):
|
|
464
|
+
try:
|
|
465
|
+
active_auth_type = str(auth_type_resolver(profile_name) or "none")
|
|
466
|
+
except Exception:
|
|
467
|
+
active_auth_type = "config_error"
|
|
461
468
|
if workspace_id is None:
|
|
462
469
|
workspace_label = "(not selected)"
|
|
463
470
|
elif workspace_name:
|
|
@@ -466,7 +473,8 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
|
|
|
466
473
|
workspace_label = str(workspace_id)
|
|
467
474
|
lines: list[str] = []
|
|
468
475
|
if not hide_context_line:
|
|
469
|
-
|
|
476
|
+
auth_label = f" auth={active_auth_type}" if active_auth_type is not None else ""
|
|
477
|
+
lines.append(f"Context: profile={profile_name}{auth_label} workspace={workspace_label}")
|
|
470
478
|
if spec.cli_context_write and profile_name == "default":
|
|
471
479
|
lines.append("Warning: using default profile for a workspace-sensitive write command")
|
|
472
480
|
if not lines:
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from urllib.parse import parse_qs, urlsplit
|
|
8
|
+
|
|
9
|
+
from ..errors import QingflowApiError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CALLBACK_PATH = "/oauth/callback"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class OAuthCallbackResult:
|
|
17
|
+
code: str
|
|
18
|
+
state: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OAuthCallbackServer:
|
|
22
|
+
def __init__(self, expected_state: str) -> None:
|
|
23
|
+
self._expected_state = expected_state
|
|
24
|
+
self._result: OAuthCallbackResult | None = None
|
|
25
|
+
self._error: QingflowApiError | None = None
|
|
26
|
+
self._completed = threading.Event()
|
|
27
|
+
self._result_lock = threading.Lock()
|
|
28
|
+
self._started = False
|
|
29
|
+
self._closed = False
|
|
30
|
+
handler = self._handler_type()
|
|
31
|
+
self._server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
32
|
+
self._server.daemon_threads = True
|
|
33
|
+
self._thread = threading.Thread(target=self._server.serve_forever, name="qingflow-oauth-callback", daemon=True)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def redirect_uri(self) -> str:
|
|
37
|
+
port = int(self._server.server_address[1])
|
|
38
|
+
return f"http://127.0.0.1:{port}{CALLBACK_PATH}"
|
|
39
|
+
|
|
40
|
+
def start(self) -> None:
|
|
41
|
+
if self._started:
|
|
42
|
+
return
|
|
43
|
+
self._started = True
|
|
44
|
+
self._thread.start()
|
|
45
|
+
|
|
46
|
+
def wait(self, timeout_seconds: float = 300.0) -> OAuthCallbackResult:
|
|
47
|
+
try:
|
|
48
|
+
if not self._completed.wait(timeout=max(0.0, timeout_seconds)):
|
|
49
|
+
raise QingflowApiError(
|
|
50
|
+
category="auth",
|
|
51
|
+
message="OAuth authorization timed out. Run auth login --method oauth again.",
|
|
52
|
+
details={"reason": "oauth_callback_timeout"},
|
|
53
|
+
)
|
|
54
|
+
if self._error is not None:
|
|
55
|
+
raise self._error
|
|
56
|
+
if self._result is None:
|
|
57
|
+
raise QingflowApiError(category="auth", message="OAuth callback did not return an authorization code.")
|
|
58
|
+
return self._result
|
|
59
|
+
finally:
|
|
60
|
+
self.close()
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
if self._closed:
|
|
64
|
+
return
|
|
65
|
+
self._closed = True
|
|
66
|
+
if not self._started:
|
|
67
|
+
self._server.server_close()
|
|
68
|
+
return
|
|
69
|
+
self._server.shutdown()
|
|
70
|
+
self._server.server_close()
|
|
71
|
+
if self._thread.is_alive() and threading.current_thread() is not self._thread:
|
|
72
|
+
self._thread.join(timeout=2.0)
|
|
73
|
+
|
|
74
|
+
def _handler_type(self) -> type[BaseHTTPRequestHandler]:
|
|
75
|
+
callback_server = self
|
|
76
|
+
|
|
77
|
+
class Handler(BaseHTTPRequestHandler):
|
|
78
|
+
def do_GET(self) -> None: # noqa: N802
|
|
79
|
+
callback_server._handle(self)
|
|
80
|
+
|
|
81
|
+
def do_POST(self) -> None: # noqa: N802
|
|
82
|
+
self.send_error(405)
|
|
83
|
+
|
|
84
|
+
def log_message(self, _format: str, *_args: object) -> None:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
return Handler
|
|
88
|
+
|
|
89
|
+
def _handle(self, handler: BaseHTTPRequestHandler) -> None:
|
|
90
|
+
parsed = urlsplit(handler.path)
|
|
91
|
+
if parsed.path != CALLBACK_PATH:
|
|
92
|
+
self._respond(handler, 404, "OAuth callback path not found.")
|
|
93
|
+
return
|
|
94
|
+
query = parse_qs(parsed.query, keep_blank_values=True, strict_parsing=False)
|
|
95
|
+
state_values = query.get("state") or []
|
|
96
|
+
if len(state_values) != 1 or not secrets.compare_digest(state_values[0], self._expected_state):
|
|
97
|
+
self._respond(handler, 400, "OAuth callback state is invalid. Return to the CLI and retry login.")
|
|
98
|
+
return
|
|
99
|
+
with self._result_lock:
|
|
100
|
+
if self._completed.is_set():
|
|
101
|
+
self._respond(handler, 409, "OAuth callback has already been handled.")
|
|
102
|
+
return
|
|
103
|
+
error_values = query.get("error") or []
|
|
104
|
+
code_values = query.get("code") or []
|
|
105
|
+
if len(error_values) == 1 and error_values[0]:
|
|
106
|
+
error_code = error_values[0]
|
|
107
|
+
description_values = query.get("error_description") or []
|
|
108
|
+
description = description_values[0] if len(description_values) == 1 else None
|
|
109
|
+
self._error = QingflowApiError(
|
|
110
|
+
category="auth",
|
|
111
|
+
message=description or f"OAuth authorization failed: {error_code}.",
|
|
112
|
+
details={"reason": "oauth_authorization_failed", "oauth_error": error_code},
|
|
113
|
+
)
|
|
114
|
+
self._completed.set()
|
|
115
|
+
self._respond(handler, 200, "Authorization was not completed. You can return to the CLI.")
|
|
116
|
+
return
|
|
117
|
+
if len(code_values) != 1 or not code_values[0]:
|
|
118
|
+
self._error = QingflowApiError(
|
|
119
|
+
category="auth",
|
|
120
|
+
message="OAuth callback did not contain exactly one authorization code.",
|
|
121
|
+
details={"reason": "oauth_callback_invalid"},
|
|
122
|
+
)
|
|
123
|
+
self._completed.set()
|
|
124
|
+
self._respond(handler, 400, "Authorization response is invalid. Return to the CLI and retry login.")
|
|
125
|
+
return
|
|
126
|
+
self._result = OAuthCallbackResult(code=code_values[0], state=state_values[0])
|
|
127
|
+
self._completed.set()
|
|
128
|
+
self._respond(handler, 200, "Authorization completed. You can close this page and return to the CLI.")
|
|
129
|
+
|
|
130
|
+
def _respond(self, handler: BaseHTTPRequestHandler, status: int, message: str) -> None:
|
|
131
|
+
body = (
|
|
132
|
+
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Qingflow CLI</title></head>"
|
|
133
|
+
f"<body><main><h1>Qingflow CLI</h1><p>{message}</p></main></body></html>"
|
|
134
|
+
).encode("utf-8")
|
|
135
|
+
handler.send_response(status)
|
|
136
|
+
handler.send_header("Content-Type", "text/html; charset=utf-8")
|
|
137
|
+
handler.send_header("Cache-Control", "no-store")
|
|
138
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
139
|
+
handler.end_headers()
|
|
140
|
+
handler.wfile.write(body)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlencode, urlsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ..config import DEFAULT_CLI_OAUTH_RESOURCE_URL, get_timeout_seconds, normalize_base_url
|
|
14
|
+
from ..errors import QingflowApiError
|
|
15
|
+
from ..oauth_token_store import OAuthTokenBundle
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CLI_OAUTH_CLIENT_ID = "qf_public_vlaBZbGtWzkKzsp0lmx-vwidnNuI5_l9Dj1Vv0HKSJ8"
|
|
19
|
+
CLI_OAUTH_SOURCE_CODE = "qingflow_cli"
|
|
20
|
+
CLI_OAUTH_SCOPE = "read write"
|
|
21
|
+
CLI_OAUTH_RESOURCE_URI = DEFAULT_CLI_OAUTH_RESOURCE_URL
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class OAuthServerMetadata:
|
|
26
|
+
issuer: str
|
|
27
|
+
authorization_endpoint: str
|
|
28
|
+
token_endpoint: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class PKCEAuthorization:
|
|
33
|
+
state: str
|
|
34
|
+
verifier: str
|
|
35
|
+
challenge: str
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def create(cls) -> "PKCEAuthorization":
|
|
39
|
+
verifier = _base64url(secrets.token_bytes(64))
|
|
40
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
41
|
+
return cls(
|
|
42
|
+
state=_base64url(secrets.token_bytes(32)),
|
|
43
|
+
verifier=verifier,
|
|
44
|
+
challenge=_base64url(digest),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OAuthClient:
|
|
49
|
+
def __init__(self, client: httpx.Client | None = None) -> None:
|
|
50
|
+
self._owns_client = client is None
|
|
51
|
+
self._client = client or httpx.Client(
|
|
52
|
+
timeout=get_timeout_seconds(),
|
|
53
|
+
follow_redirects=True,
|
|
54
|
+
trust_env=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
if self._owns_client:
|
|
59
|
+
self._client.close()
|
|
60
|
+
|
|
61
|
+
def discover(self, base_url: str) -> OAuthServerMetadata:
|
|
62
|
+
normalized = normalize_base_url(base_url)
|
|
63
|
+
if not normalized:
|
|
64
|
+
raise QingflowApiError.config_error("OAuth base_url is required.")
|
|
65
|
+
url = f"{normalized}/.well-known/oauth-authorization-server"
|
|
66
|
+
try:
|
|
67
|
+
response = self._client.get(url, headers={"Accept": "application/json"})
|
|
68
|
+
except httpx.HTTPError as exc:
|
|
69
|
+
raise _network_error("OAuth metadata request failed", exc) from exc
|
|
70
|
+
payload = self._json_response(response, operation="OAuth metadata discovery")
|
|
71
|
+
issuer = _text(payload.get("issuer"))
|
|
72
|
+
authorization_endpoint = _text(payload.get("authorization_endpoint"))
|
|
73
|
+
token_endpoint = _text(payload.get("token_endpoint"))
|
|
74
|
+
missing = [
|
|
75
|
+
name
|
|
76
|
+
for name, value in (
|
|
77
|
+
("issuer", issuer),
|
|
78
|
+
("authorization_endpoint", authorization_endpoint),
|
|
79
|
+
("token_endpoint", token_endpoint),
|
|
80
|
+
)
|
|
81
|
+
if not value
|
|
82
|
+
]
|
|
83
|
+
if missing:
|
|
84
|
+
raise QingflowApiError.config_error(
|
|
85
|
+
"OAuth metadata is missing required fields: " + ", ".join(missing) + "."
|
|
86
|
+
)
|
|
87
|
+
self._validate_endpoint(normalized, authorization_endpoint, "authorization_endpoint")
|
|
88
|
+
self._validate_endpoint(normalized, token_endpoint, "token_endpoint")
|
|
89
|
+
challenge_methods = payload.get("code_challenge_methods_supported")
|
|
90
|
+
if not isinstance(challenge_methods, list) or "S256" not in challenge_methods:
|
|
91
|
+
raise QingflowApiError.config_error("OAuth server metadata does not advertise PKCE S256 support.")
|
|
92
|
+
grant_types = payload.get("grant_types_supported")
|
|
93
|
+
if not isinstance(grant_types, list) or not {"authorization_code", "refresh_token"}.issubset(grant_types):
|
|
94
|
+
raise QingflowApiError.config_error(
|
|
95
|
+
"OAuth server metadata does not advertise authorization_code and refresh_token support."
|
|
96
|
+
)
|
|
97
|
+
return OAuthServerMetadata(
|
|
98
|
+
issuer=issuer,
|
|
99
|
+
authorization_endpoint=authorization_endpoint,
|
|
100
|
+
token_endpoint=token_endpoint,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def authorization_url(
|
|
104
|
+
self,
|
|
105
|
+
metadata: OAuthServerMetadata,
|
|
106
|
+
*,
|
|
107
|
+
redirect_uri: str,
|
|
108
|
+
resource: str,
|
|
109
|
+
pkce: PKCEAuthorization,
|
|
110
|
+
) -> str:
|
|
111
|
+
query = urlencode(
|
|
112
|
+
{
|
|
113
|
+
"response_type": "code",
|
|
114
|
+
"client_id": CLI_OAUTH_CLIENT_ID,
|
|
115
|
+
"redirect_uri": redirect_uri,
|
|
116
|
+
"state": pkce.state,
|
|
117
|
+
"code_challenge": pkce.challenge,
|
|
118
|
+
"code_challenge_method": "S256",
|
|
119
|
+
"scope": CLI_OAUTH_SCOPE,
|
|
120
|
+
"resource": resource,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
separator = "&" if "?" in metadata.authorization_endpoint else "?"
|
|
124
|
+
return f"{metadata.authorization_endpoint}{separator}{query}"
|
|
125
|
+
|
|
126
|
+
def exchange_code(
|
|
127
|
+
self,
|
|
128
|
+
metadata: OAuthServerMetadata,
|
|
129
|
+
*,
|
|
130
|
+
code: str,
|
|
131
|
+
redirect_uri: str,
|
|
132
|
+
verifier: str,
|
|
133
|
+
) -> OAuthTokenBundle:
|
|
134
|
+
payload = self._token_request(
|
|
135
|
+
metadata.token_endpoint,
|
|
136
|
+
{
|
|
137
|
+
"grant_type": "authorization_code",
|
|
138
|
+
"client_id": CLI_OAUTH_CLIENT_ID,
|
|
139
|
+
"code": code,
|
|
140
|
+
"redirect_uri": redirect_uri,
|
|
141
|
+
"code_verifier": verifier,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
return self._bundle_from_response(payload, generation=0)
|
|
145
|
+
|
|
146
|
+
def refresh(
|
|
147
|
+
self,
|
|
148
|
+
metadata: OAuthServerMetadata,
|
|
149
|
+
*,
|
|
150
|
+
refresh_token: str,
|
|
151
|
+
generation: int,
|
|
152
|
+
) -> OAuthTokenBundle:
|
|
153
|
+
payload = self._token_request(
|
|
154
|
+
metadata.token_endpoint,
|
|
155
|
+
{
|
|
156
|
+
"grant_type": "refresh_token",
|
|
157
|
+
"client_id": CLI_OAUTH_CLIENT_ID,
|
|
158
|
+
"refresh_token": refresh_token,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
return self._bundle_from_response(payload, generation=generation + 1)
|
|
162
|
+
|
|
163
|
+
def revoke(self, base_url: str, refresh_token: str) -> None:
|
|
164
|
+
normalized = normalize_base_url(base_url)
|
|
165
|
+
if not normalized:
|
|
166
|
+
raise QingflowApiError.config_error("OAuth base_url is required for logout.")
|
|
167
|
+
try:
|
|
168
|
+
response = self._client.post(
|
|
169
|
+
f"{normalized}/oauth/revoke",
|
|
170
|
+
data={
|
|
171
|
+
"client_id": CLI_OAUTH_CLIENT_ID,
|
|
172
|
+
"token": refresh_token,
|
|
173
|
+
"token_type_hint": "refresh_token",
|
|
174
|
+
},
|
|
175
|
+
headers={"Accept": "application/json"},
|
|
176
|
+
)
|
|
177
|
+
except httpx.HTTPError as exc:
|
|
178
|
+
raise _network_error("OAuth revocation request failed", exc) from exc
|
|
179
|
+
if response.status_code not in {200, 204}:
|
|
180
|
+
self._raise_protocol_error(response, operation="OAuth revocation")
|
|
181
|
+
|
|
182
|
+
def _token_request(self, endpoint: str, form: dict[str, str]) -> dict[str, Any]:
|
|
183
|
+
try:
|
|
184
|
+
response = self._client.post(
|
|
185
|
+
endpoint,
|
|
186
|
+
data=form,
|
|
187
|
+
headers={"Accept": "application/json"},
|
|
188
|
+
)
|
|
189
|
+
except httpx.HTTPError as exc:
|
|
190
|
+
raise _network_error("OAuth token request failed", exc) from exc
|
|
191
|
+
return self._json_response(response, operation="OAuth token request")
|
|
192
|
+
|
|
193
|
+
def _json_response(self, response: httpx.Response, *, operation: str) -> dict[str, Any]:
|
|
194
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
195
|
+
self._raise_protocol_error(response, operation=operation)
|
|
196
|
+
try:
|
|
197
|
+
payload = response.json()
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
raise QingflowApiError(
|
|
200
|
+
category="auth",
|
|
201
|
+
message=f"{operation} returned invalid JSON.",
|
|
202
|
+
http_status=response.status_code,
|
|
203
|
+
) from exc
|
|
204
|
+
if not isinstance(payload, dict):
|
|
205
|
+
raise QingflowApiError(
|
|
206
|
+
category="auth",
|
|
207
|
+
message=f"{operation} returned an invalid response.",
|
|
208
|
+
http_status=response.status_code,
|
|
209
|
+
)
|
|
210
|
+
return payload
|
|
211
|
+
|
|
212
|
+
def _raise_protocol_error(self, response: httpx.Response, *, operation: str) -> None:
|
|
213
|
+
try:
|
|
214
|
+
payload = response.json()
|
|
215
|
+
except ValueError:
|
|
216
|
+
payload = {}
|
|
217
|
+
oauth_error = _text(payload.get("error")) if isinstance(payload, dict) else None
|
|
218
|
+
description = _text(payload.get("error_description")) if isinstance(payload, dict) else None
|
|
219
|
+
raise QingflowApiError(
|
|
220
|
+
category="auth" if response.status_code < 500 else "network",
|
|
221
|
+
message=description or f"{operation} failed with HTTP {response.status_code}.",
|
|
222
|
+
http_status=response.status_code,
|
|
223
|
+
details={"reason": "oauth_protocol_error", "oauth_error": oauth_error or "unknown"},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _bundle_from_response(self, payload: dict[str, Any], *, generation: int) -> OAuthTokenBundle:
|
|
227
|
+
access_token = _text(payload.get("access_token"))
|
|
228
|
+
refresh_token = _text(payload.get("refresh_token"))
|
|
229
|
+
token_type = _text(payload.get("token_type"))
|
|
230
|
+
try:
|
|
231
|
+
expires_in = int(payload.get("expires_in"))
|
|
232
|
+
except (TypeError, ValueError):
|
|
233
|
+
expires_in = 0
|
|
234
|
+
if not access_token or not refresh_token or token_type is None or token_type.lower() != "bearer" or expires_in <= 0:
|
|
235
|
+
raise QingflowApiError(
|
|
236
|
+
category="auth",
|
|
237
|
+
message="OAuth token response is missing a valid access token, refresh token, token type, or expiry.",
|
|
238
|
+
details={"reason": "oauth_token_response_invalid"},
|
|
239
|
+
)
|
|
240
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
241
|
+
return OAuthTokenBundle(
|
|
242
|
+
access_token=access_token,
|
|
243
|
+
refresh_token=refresh_token,
|
|
244
|
+
access_expires_at=expires_at.isoformat(),
|
|
245
|
+
generation=generation,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _validate_endpoint(self, base_url: str, endpoint: str, field: str) -> None:
|
|
249
|
+
base = urlsplit(base_url)
|
|
250
|
+
candidate = urlsplit(endpoint)
|
|
251
|
+
if candidate.scheme not in {"http", "https"} or not candidate.netloc:
|
|
252
|
+
raise QingflowApiError.config_error(f"OAuth metadata {field} is not an absolute HTTP URL.")
|
|
253
|
+
if (candidate.scheme.lower(), candidate.netloc.lower()) != (base.scheme.lower(), base.netloc.lower()):
|
|
254
|
+
raise QingflowApiError.config_error(f"OAuth metadata {field} must use the configured base_url origin.")
|
|
255
|
+
base_path = base.path.rstrip("/")
|
|
256
|
+
if base_path and candidate.path != base_path and not candidate.path.startswith(base_path + "/"):
|
|
257
|
+
raise QingflowApiError.config_error(f"OAuth metadata {field} must remain under the configured base_url path.")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _base64url(value: bytes) -> str:
|
|
261
|
+
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _text(value: object) -> str | None:
|
|
265
|
+
if value is None:
|
|
266
|
+
return None
|
|
267
|
+
normalized = str(value).strip()
|
|
268
|
+
return normalized or None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _network_error(message: str, error: Exception) -> QingflowApiError:
|
|
272
|
+
return QingflowApiError(
|
|
273
|
+
category="network",
|
|
274
|
+
message=f"{message}: {error}",
|
|
275
|
+
details={"reason": "oauth_network_error"},
|
|
276
|
+
)
|