@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.
@@ -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
- 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
- )
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
- return context.auth.auth_use_credential(
99
- profile=args.profile,
100
- base_url=args.base_url,
101
- qf_version=args.qf_version,
102
- credential=credential,
103
- persist=bool(args.persist),
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: SessionStore
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
- sessions = SessionStore()
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,
@@ -494,6 +494,13 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
494
494
  session_profile = None
495
495
  workspace_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
496
496
  workspace_name = getattr(session_profile, "selected_ws_name", None) if session_profile is not None else None
497
+ active_auth_type = None
498
+ auth_type_resolver = getattr(sessions, "active_auth_type", None)
499
+ if callable(auth_type_resolver):
500
+ try:
501
+ active_auth_type = str(auth_type_resolver(profile_name) or "none")
502
+ except Exception:
503
+ active_auth_type = "config_error"
497
504
  if workspace_id is None:
498
505
  workspace_label = "(not selected)"
499
506
  elif workspace_name:
@@ -502,7 +509,8 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
502
509
  workspace_label = str(workspace_id)
503
510
  lines: list[str] = []
504
511
  if not hide_context_line:
505
- lines.append(f"Context: profile={profile_name} workspace={workspace_label}")
512
+ auth_label = f" auth={active_auth_type}" if active_auth_type is not None else ""
513
+ lines.append(f"Context: profile={profile_name}{auth_label} workspace={workspace_label}")
506
514
  if spec.cli_context_write and profile_name == "default":
507
515
  lines.append("Warning: using default profile for a workspace-sensitive write command")
508
516
  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
+ )