@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
|
@@ -26,6 +26,7 @@ AUTH_CONTEXT_KEY_PREFIX = "mcp:auth:context"
|
|
|
26
26
|
AUTH_CONTEXT_LOCK_KEY_PREFIX = "mcp:auth:context:lock"
|
|
27
27
|
AUTH_CONTEXT_NEGATIVE_KEY_PREFIX = "mcp:auth:context:negative"
|
|
28
28
|
ACCESS_PLATFORM_KEY_PREFIX = "oauth2:access-platform:"
|
|
29
|
+
ACCESS_CONTEXT_KEY_PREFIX = "auth:access-context:"
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
@dataclass(slots=True)
|
|
@@ -108,12 +109,31 @@ class CloudAuthBroker:
|
|
|
108
109
|
normalized_token = self._normalize_text(access_token)
|
|
109
110
|
if normalized_token is None:
|
|
110
111
|
return ""
|
|
112
|
+
access_context = self.resolve_access_context(normalized_token)
|
|
113
|
+
if access_context is not None:
|
|
114
|
+
source_code = self._normalize_text(access_context.get("source_code"))
|
|
115
|
+
if source_code is not None:
|
|
116
|
+
return source_code
|
|
111
117
|
try:
|
|
112
118
|
value = self.cache.get_text(f"{ACCESS_PLATFORM_KEY_PREFIX}{normalized_token}")
|
|
113
119
|
except QingflowApiError:
|
|
114
120
|
return ""
|
|
115
121
|
return self._normalize_text(value) or ""
|
|
116
122
|
|
|
123
|
+
def resolve_access_context(self, access_token: str) -> JSONObject | None:
|
|
124
|
+
normalized_token = self._normalize_text(access_token)
|
|
125
|
+
if normalized_token is None:
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
value = self.cache.get_json(
|
|
129
|
+
f"{ACCESS_CONTEXT_KEY_PREFIX}{sha256(normalized_token.encode('utf-8')).hexdigest()}"
|
|
130
|
+
)
|
|
131
|
+
except QingflowApiError:
|
|
132
|
+
return None
|
|
133
|
+
if not isinstance(value, dict):
|
|
134
|
+
return None
|
|
135
|
+
return value
|
|
136
|
+
|
|
117
137
|
@staticmethod
|
|
118
138
|
def credential_hash(credential: str) -> str:
|
|
119
139
|
return sha256(credential.encode("utf-8")).hexdigest()
|
|
@@ -26,6 +26,7 @@ AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"
|
|
|
26
26
|
OAUTH_ENDPOINT_PATHS = {
|
|
27
27
|
"authorization_endpoint": "/oauth/authorize",
|
|
28
28
|
"token_endpoint": "/oauth/token",
|
|
29
|
+
"revocation_endpoint": "/oauth/revoke",
|
|
29
30
|
"registration_endpoint": "/oauth/register",
|
|
30
31
|
}
|
|
31
32
|
FORWARDED_REQUEST_HEADERS = {"accept", "content-type"}
|
|
@@ -12,6 +12,7 @@ DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
|
|
|
12
12
|
DEFAULT_RECORD_LIST_TYPE = 8
|
|
13
13
|
ATTACHMENT_QUESTION_TYPE = 13
|
|
14
14
|
DEFAULT_BASE_URL = "https://qingflow.com/api"
|
|
15
|
+
DEFAULT_CLI_OAUTH_RESOURCE_URL = "https://mcp.qingflow.com/mcp"
|
|
15
16
|
DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
|
|
16
17
|
DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
|
|
17
18
|
DEFAULT_REPOSITORY_GIT_REMOTE_TEMPLATE = "git@hackers.oalite.com:{group}/{repo}.git"
|
|
@@ -165,6 +166,17 @@ def get_default_qf_version() -> str | None:
|
|
|
165
166
|
return normalized or None
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
def get_cli_oauth_resource_url() -> str:
|
|
170
|
+
"""获取 CLI OAuth 受保护资源地址。"""
|
|
171
|
+
value = get_config_value(
|
|
172
|
+
"cli.oauth.resource_url",
|
|
173
|
+
env_var="QINGFLOW_MCP_OAUTH_RESOURCE_URL",
|
|
174
|
+
default=DEFAULT_CLI_OAUTH_RESOURCE_URL,
|
|
175
|
+
)
|
|
176
|
+
normalized = normalize_base_url(str(value or "").strip())
|
|
177
|
+
return normalized or DEFAULT_CLI_OAUTH_RESOURCE_URL
|
|
178
|
+
|
|
179
|
+
|
|
168
180
|
def get_feedback_qsource_token() -> str | None:
|
|
169
181
|
"""获取反馈 q-source 被动入口 token"""
|
|
170
182
|
value = get_config_value(
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator, Literal
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from .config import get_mcp_home, normalize_base_url
|
|
14
|
+
from .errors import QingflowApiError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
OAUTH_PROFILE_SCHEMA_VERSION = 1
|
|
18
|
+
OAuthStateValue = Literal["oauth", "none"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _utcnow() -> str:
|
|
22
|
+
return datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def profile_storage_key(profile: str) -> str:
|
|
26
|
+
return hashlib.sha256(profile.encode("utf-8")).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class OAuthProfileState:
|
|
31
|
+
active_auth_type: OAuthStateValue
|
|
32
|
+
profile: str
|
|
33
|
+
base_url: str | None = None
|
|
34
|
+
qf_version: str | None = None
|
|
35
|
+
qf_version_source: str | None = None
|
|
36
|
+
uid: int | None = None
|
|
37
|
+
email: str | None = None
|
|
38
|
+
nick_name: str | None = None
|
|
39
|
+
selected_ws_id: int | None = None
|
|
40
|
+
selected_ws_name: str | None = None
|
|
41
|
+
persisted: bool = True
|
|
42
|
+
secret_storage: str | None = None
|
|
43
|
+
issuer: str | None = None
|
|
44
|
+
client_id: str | None = None
|
|
45
|
+
resource: str | None = None
|
|
46
|
+
source_code: str | None = None
|
|
47
|
+
access_expires_at: str | None = None
|
|
48
|
+
created_at: str = ""
|
|
49
|
+
updated_at: str = ""
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, profile: str, value: object) -> "OAuthProfileState":
|
|
53
|
+
if not isinstance(value, dict):
|
|
54
|
+
raise _config_error(profile, "profile entry must be an object")
|
|
55
|
+
active_auth_type = value.get("active_auth_type")
|
|
56
|
+
if active_auth_type not in {"oauth", "none"}:
|
|
57
|
+
raise _config_error(profile, "active_auth_type must be 'oauth' or 'none'")
|
|
58
|
+
stored_profile = value.get("profile")
|
|
59
|
+
if stored_profile != profile:
|
|
60
|
+
raise _config_error(profile, "stored profile name does not match the requested profile")
|
|
61
|
+
secret_storage = _normalized_text(value.get("secret_storage"))
|
|
62
|
+
if active_auth_type == "oauth":
|
|
63
|
+
required_text = ("base_url", "issuer", "client_id", "resource", "source_code", "access_expires_at")
|
|
64
|
+
missing = [name for name in required_text if not _normalized_text(value.get(name))]
|
|
65
|
+
if secret_storage not in {"keyring", "file"}:
|
|
66
|
+
missing.append("secret_storage")
|
|
67
|
+
uid = _positive_int(value.get("uid"))
|
|
68
|
+
if uid is None:
|
|
69
|
+
missing.append("uid")
|
|
70
|
+
if missing:
|
|
71
|
+
raise _config_error(profile, "missing OAuth fields: " + ", ".join(missing))
|
|
72
|
+
created_at = _normalized_text(value.get("created_at")) or _utcnow()
|
|
73
|
+
updated_at = _normalized_text(value.get("updated_at")) or created_at
|
|
74
|
+
return cls(
|
|
75
|
+
active_auth_type=active_auth_type,
|
|
76
|
+
profile=profile,
|
|
77
|
+
base_url=_normalized_text(value.get("base_url")),
|
|
78
|
+
qf_version=_normalized_text(value.get("qf_version")),
|
|
79
|
+
qf_version_source=_normalized_text(value.get("qf_version_source")),
|
|
80
|
+
uid=_positive_int(value.get("uid")),
|
|
81
|
+
email=_normalized_text(value.get("email")),
|
|
82
|
+
nick_name=_normalized_text(value.get("nick_name")),
|
|
83
|
+
selected_ws_id=_positive_int(value.get("selected_ws_id")),
|
|
84
|
+
selected_ws_name=_normalized_text(value.get("selected_ws_name")),
|
|
85
|
+
persisted=bool(value.get("persisted", True)),
|
|
86
|
+
secret_storage=secret_storage,
|
|
87
|
+
issuer=_normalized_text(value.get("issuer")),
|
|
88
|
+
client_id=_normalized_text(value.get("client_id")),
|
|
89
|
+
resource=_normalized_text(value.get("resource")),
|
|
90
|
+
source_code=_normalized_text(value.get("source_code")),
|
|
91
|
+
access_expires_at=_normalized_text(value.get("access_expires_at")),
|
|
92
|
+
created_at=created_at,
|
|
93
|
+
updated_at=updated_at,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, object]:
|
|
97
|
+
return asdict(self)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OAuthProfileStore:
|
|
101
|
+
def __init__(self, base_dir: Path | None = None) -> None:
|
|
102
|
+
self.base_dir = Path(base_dir) if base_dir is not None else get_mcp_home()
|
|
103
|
+
self._profiles_dir = self.base_dir / "oauth-profiles"
|
|
104
|
+
self._locks_dir = self.base_dir / "locks"
|
|
105
|
+
|
|
106
|
+
def get(self, profile: str) -> OAuthProfileState | None:
|
|
107
|
+
path = self.profile_path(profile)
|
|
108
|
+
if not path.exists():
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
112
|
+
payload = json.load(handle)
|
|
113
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
114
|
+
raise _config_error(profile, f"state file cannot be read: {exc}") from exc
|
|
115
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != OAUTH_PROFILE_SCHEMA_VERSION:
|
|
116
|
+
raise _config_error(profile, "unsupported or missing schema_version")
|
|
117
|
+
profiles = payload.get("profiles")
|
|
118
|
+
if not isinstance(profiles, dict) or profile not in profiles:
|
|
119
|
+
raise _config_error(profile, "state file does not contain the requested profile")
|
|
120
|
+
return OAuthProfileState.from_dict(profile, profiles[profile])
|
|
121
|
+
|
|
122
|
+
def save(self, state: OAuthProfileState, *, locked: bool = False) -> None:
|
|
123
|
+
if locked:
|
|
124
|
+
self._save_locked(state)
|
|
125
|
+
return
|
|
126
|
+
with self.lock(state.profile):
|
|
127
|
+
self._save_locked(state)
|
|
128
|
+
|
|
129
|
+
def set_none(self, profile: str, *, locked: bool = False) -> OAuthProfileState:
|
|
130
|
+
now = _utcnow()
|
|
131
|
+
try:
|
|
132
|
+
previous = self.get(profile)
|
|
133
|
+
except QingflowApiError:
|
|
134
|
+
previous = None
|
|
135
|
+
state = OAuthProfileState(
|
|
136
|
+
active_auth_type="none",
|
|
137
|
+
profile=profile,
|
|
138
|
+
persisted=True,
|
|
139
|
+
created_at=previous.created_at if previous is not None else now,
|
|
140
|
+
updated_at=now,
|
|
141
|
+
)
|
|
142
|
+
self.save(state, locked=locked)
|
|
143
|
+
return state
|
|
144
|
+
|
|
145
|
+
def delete(self, profile: str, *, locked: bool = False) -> None:
|
|
146
|
+
if not locked:
|
|
147
|
+
with self.lock(profile):
|
|
148
|
+
self.delete(profile, locked=True)
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
self.profile_path(profile).unlink()
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
return
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
raise _config_error(profile, f"state file cannot be removed: {exc}") from exc
|
|
156
|
+
|
|
157
|
+
@contextmanager
|
|
158
|
+
def lock(self, profile: str) -> Iterator[None]:
|
|
159
|
+
lock_path = self._locks_dir / f"{profile_storage_key(profile)}.oauth.lock"
|
|
160
|
+
with _FileLock(lock_path):
|
|
161
|
+
yield
|
|
162
|
+
|
|
163
|
+
def profile_path(self, profile: str) -> Path:
|
|
164
|
+
return self._profiles_dir / f"{profile_storage_key(profile)}.json"
|
|
165
|
+
|
|
166
|
+
def _save_locked(self, state: OAuthProfileState) -> None:
|
|
167
|
+
normalized_base_url = normalize_base_url(state.base_url) if state.base_url else None
|
|
168
|
+
if state.active_auth_type == "oauth" and not normalized_base_url:
|
|
169
|
+
raise _config_error(state.profile, "base_url is invalid")
|
|
170
|
+
state.base_url = normalized_base_url
|
|
171
|
+
state.updated_at = _utcnow()
|
|
172
|
+
if not state.created_at:
|
|
173
|
+
state.created_at = state.updated_at
|
|
174
|
+
payload = {
|
|
175
|
+
"schema_version": OAUTH_PROFILE_SCHEMA_VERSION,
|
|
176
|
+
"profiles": {state.profile: state.to_dict()},
|
|
177
|
+
}
|
|
178
|
+
_atomic_json_write(self.profile_path(state.profile), payload)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _FileLock:
|
|
182
|
+
def __init__(self, path: Path) -> None:
|
|
183
|
+
self.path = path
|
|
184
|
+
self._handle = None
|
|
185
|
+
|
|
186
|
+
def __enter__(self) -> "_FileLock":
|
|
187
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
self._handle = self.path.open("a+b")
|
|
189
|
+
self._handle.seek(0, os.SEEK_END)
|
|
190
|
+
if self._handle.tell() == 0:
|
|
191
|
+
self._handle.write(b"0")
|
|
192
|
+
self._handle.flush()
|
|
193
|
+
self._handle.seek(0)
|
|
194
|
+
if os.name == "nt":
|
|
195
|
+
import msvcrt
|
|
196
|
+
|
|
197
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
198
|
+
else:
|
|
199
|
+
import fcntl
|
|
200
|
+
|
|
201
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def __exit__(self, _exc_type, _exc, _traceback) -> None:
|
|
205
|
+
if self._handle is None:
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
self._handle.seek(0)
|
|
209
|
+
if os.name == "nt":
|
|
210
|
+
import msvcrt
|
|
211
|
+
|
|
212
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
213
|
+
else:
|
|
214
|
+
import fcntl
|
|
215
|
+
|
|
216
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
|
|
217
|
+
finally:
|
|
218
|
+
self._handle.close()
|
|
219
|
+
self._handle = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _atomic_json_write(path: Path, payload: object) -> None:
|
|
223
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid4().hex}.tmp"
|
|
225
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
226
|
+
try:
|
|
227
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
228
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
229
|
+
handle.flush()
|
|
230
|
+
os.fsync(handle.fileno())
|
|
231
|
+
os.replace(temporary, path)
|
|
232
|
+
try:
|
|
233
|
+
os.chmod(path, 0o600)
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
finally:
|
|
237
|
+
try:
|
|
238
|
+
temporary.unlink()
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _normalized_text(value: object) -> str | None:
|
|
244
|
+
if value is None:
|
|
245
|
+
return None
|
|
246
|
+
normalized = str(value).strip()
|
|
247
|
+
return normalized or None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _positive_int(value: object) -> int | None:
|
|
251
|
+
if value is None or isinstance(value, bool):
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
parsed = int(value)
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
return None
|
|
257
|
+
return parsed if parsed > 0 else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _config_error(profile: str, reason: str) -> QingflowApiError:
|
|
261
|
+
return QingflowApiError.config_error(
|
|
262
|
+
f"OAuth state for profile '{profile}' is invalid: {reason}.",
|
|
263
|
+
details={"reason": "oauth_state_invalid", "profile": profile},
|
|
264
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import keyring
|
|
9
|
+
except ImportError:
|
|
10
|
+
keyring = None
|
|
11
|
+
|
|
12
|
+
from .errors import QingflowApiError
|
|
13
|
+
from .json_types import KeyringBackend
|
|
14
|
+
from .oauth_profile_store import OAuthProfileStore, _atomic_json_write, profile_storage_key
|
|
15
|
+
from .session_store import KEYRING_SERVICE_NAME
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class OAuthTokenBundle:
|
|
20
|
+
access_token: str
|
|
21
|
+
refresh_token: str
|
|
22
|
+
access_expires_at: str
|
|
23
|
+
generation: int
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_json(cls, profile: str, value: object) -> "OAuthTokenBundle":
|
|
27
|
+
if not isinstance(value, dict):
|
|
28
|
+
raise _bundle_error(profile, "bundle must be an object")
|
|
29
|
+
access_token = _text(value.get("access_token"))
|
|
30
|
+
refresh_token = _text(value.get("refresh_token"))
|
|
31
|
+
access_expires_at = _text(value.get("access_expires_at"))
|
|
32
|
+
generation_value = value.get("generation")
|
|
33
|
+
if isinstance(generation_value, bool):
|
|
34
|
+
generation = -1
|
|
35
|
+
else:
|
|
36
|
+
try:
|
|
37
|
+
generation = int(generation_value)
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
generation = -1
|
|
40
|
+
missing = []
|
|
41
|
+
if not access_token:
|
|
42
|
+
missing.append("access_token")
|
|
43
|
+
if not refresh_token:
|
|
44
|
+
missing.append("refresh_token")
|
|
45
|
+
if not access_expires_at:
|
|
46
|
+
missing.append("access_expires_at")
|
|
47
|
+
if generation < 0:
|
|
48
|
+
missing.append("generation")
|
|
49
|
+
if missing:
|
|
50
|
+
raise _bundle_error(profile, "missing or invalid fields: " + ", ".join(missing))
|
|
51
|
+
return cls(
|
|
52
|
+
access_token=access_token,
|
|
53
|
+
refresh_token=refresh_token,
|
|
54
|
+
access_expires_at=access_expires_at,
|
|
55
|
+
generation=generation,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def to_json(self) -> dict[str, object]:
|
|
59
|
+
return asdict(self)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OAuthTokenStore:
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
profile_store: OAuthProfileStore,
|
|
66
|
+
*,
|
|
67
|
+
keyring_backend: KeyringBackend | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.profile_store = profile_store
|
|
70
|
+
self._keyring = keyring_backend if keyring_backend is not None else keyring
|
|
71
|
+
self._secrets_dir = profile_store.base_dir / "oauth-secrets"
|
|
72
|
+
|
|
73
|
+
def load(self, profile: str) -> OAuthTokenBundle | None:
|
|
74
|
+
state = self.profile_store.get(profile)
|
|
75
|
+
if state is not None and state.active_auth_type == "none":
|
|
76
|
+
return None
|
|
77
|
+
if state is not None and state.secret_storage == "file":
|
|
78
|
+
return self._load_file(profile)
|
|
79
|
+
if state is not None and state.secret_storage == "keyring":
|
|
80
|
+
keyring_value = self._get_keyring(profile)
|
|
81
|
+
return self._decode(profile, keyring_value, storage="keyring") if keyring_value is not None else None
|
|
82
|
+
keyring_value = self._get_keyring(profile)
|
|
83
|
+
if keyring_value is not None:
|
|
84
|
+
return self._decode(profile, keyring_value, storage="keyring")
|
|
85
|
+
return self._load_file(profile)
|
|
86
|
+
|
|
87
|
+
def _load_file(self, profile: str) -> OAuthTokenBundle | None:
|
|
88
|
+
path = self.secret_path(profile)
|
|
89
|
+
if not path.exists():
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
93
|
+
payload = json.load(handle)
|
|
94
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
95
|
+
raise _bundle_error(profile, f"file bundle cannot be read: {exc}") from exc
|
|
96
|
+
return OAuthTokenBundle.from_json(profile, payload)
|
|
97
|
+
|
|
98
|
+
def save(
|
|
99
|
+
self,
|
|
100
|
+
profile: str,
|
|
101
|
+
bundle: OAuthTokenBundle,
|
|
102
|
+
*,
|
|
103
|
+
storage: str | None = None,
|
|
104
|
+
locked: bool = False,
|
|
105
|
+
) -> str:
|
|
106
|
+
if not locked:
|
|
107
|
+
with self.profile_store.lock(profile):
|
|
108
|
+
state = self.profile_store.get(profile)
|
|
109
|
+
selected_storage = storage
|
|
110
|
+
if selected_storage is None and state is not None and state.active_auth_type == "oauth":
|
|
111
|
+
selected_storage = state.secret_storage
|
|
112
|
+
saved_storage = self.save(profile, bundle, storage=selected_storage, locked=True)
|
|
113
|
+
if state is not None and state.active_auth_type == "oauth":
|
|
114
|
+
state.secret_storage = saved_storage
|
|
115
|
+
state.access_expires_at = bundle.access_expires_at
|
|
116
|
+
self.profile_store.save(state, locked=True)
|
|
117
|
+
return saved_storage
|
|
118
|
+
if storage not in {None, "keyring", "file"}:
|
|
119
|
+
raise _bundle_error(profile, f"unsupported secret storage: {storage}")
|
|
120
|
+
encoded = json.dumps(bundle.to_json(), ensure_ascii=False, separators=(",", ":"))
|
|
121
|
+
if storage == "keyring":
|
|
122
|
+
if not self._set_keyring(profile, encoded):
|
|
123
|
+
raise _bundle_error(profile, "keyring write failed")
|
|
124
|
+
self._delete_file(profile)
|
|
125
|
+
return "keyring"
|
|
126
|
+
if storage == "file":
|
|
127
|
+
self._save_file(profile, bundle)
|
|
128
|
+
self._delete_keyring(profile)
|
|
129
|
+
return "file"
|
|
130
|
+
if self._set_keyring(profile, encoded):
|
|
131
|
+
self._delete_file(profile)
|
|
132
|
+
return "keyring"
|
|
133
|
+
self._save_file(profile, bundle)
|
|
134
|
+
self._delete_keyring(profile)
|
|
135
|
+
return "file"
|
|
136
|
+
|
|
137
|
+
def delete(self, profile: str, *, locked: bool = False) -> None:
|
|
138
|
+
if not locked:
|
|
139
|
+
with self.profile_store.lock(profile):
|
|
140
|
+
self.delete(profile, locked=True)
|
|
141
|
+
return
|
|
142
|
+
self._delete_keyring(profile)
|
|
143
|
+
self._delete_file(profile)
|
|
144
|
+
|
|
145
|
+
def secret_path(self, profile: str) -> Path:
|
|
146
|
+
return self._secrets_dir / f"{profile_storage_key(profile)}.json"
|
|
147
|
+
|
|
148
|
+
def _decode(self, profile: str, encoded: str, *, storage: str) -> OAuthTokenBundle:
|
|
149
|
+
try:
|
|
150
|
+
value = json.loads(encoded)
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise _bundle_error(profile, f"{storage} bundle is not valid JSON") from exc
|
|
153
|
+
return OAuthTokenBundle.from_json(profile, value)
|
|
154
|
+
|
|
155
|
+
def _key(self, profile: str) -> str:
|
|
156
|
+
return f"{profile}:oauth-token-set"
|
|
157
|
+
|
|
158
|
+
def _get_keyring(self, profile: str) -> str | None:
|
|
159
|
+
if self._keyring is None:
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
value = self._keyring.get_password(KEYRING_SERVICE_NAME, self._key(profile))
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
return value if value else None
|
|
166
|
+
|
|
167
|
+
def _set_keyring(self, profile: str, value: str) -> bool:
|
|
168
|
+
if self._keyring is None:
|
|
169
|
+
return False
|
|
170
|
+
try:
|
|
171
|
+
self._keyring.set_password(KEYRING_SERVICE_NAME, self._key(profile), value)
|
|
172
|
+
except Exception:
|
|
173
|
+
return False
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def _delete_keyring(self, profile: str) -> None:
|
|
177
|
+
if self._keyring is None:
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
self._keyring.delete_password(KEYRING_SERVICE_NAME, self._key(profile))
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
def _save_file(self, profile: str, bundle: OAuthTokenBundle) -> None:
|
|
185
|
+
try:
|
|
186
|
+
_atomic_json_write(self.secret_path(profile), bundle.to_json())
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
raise _bundle_error(profile, f"file bundle cannot be written: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
def _delete_file(self, profile: str) -> None:
|
|
191
|
+
try:
|
|
192
|
+
self.secret_path(profile).unlink()
|
|
193
|
+
except FileNotFoundError:
|
|
194
|
+
return
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
raise _bundle_error(profile, f"file bundle cannot be removed: {exc}") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _text(value: object) -> str | None:
|
|
200
|
+
if value is None:
|
|
201
|
+
return None
|
|
202
|
+
normalized = str(value).strip()
|
|
203
|
+
return normalized or None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _bundle_error(profile: str, reason: str) -> QingflowApiError:
|
|
207
|
+
return QingflowApiError.config_error(
|
|
208
|
+
f"OAuth token bundle for profile '{profile}' is invalid: {reason}.",
|
|
209
|
+
details={"reason": "oauth_token_bundle_invalid", "profile": profile},
|
|
210
|
+
)
|
|
@@ -109,9 +109,22 @@ class CloudMcpAuthApp:
|
|
|
109
109
|
and self._oauth_token_resolver is not None
|
|
110
110
|
and not auth_value.startswith("mcp_")
|
|
111
111
|
)
|
|
112
|
+
access_context: dict[str, Any] | None = None
|
|
112
113
|
try:
|
|
113
114
|
if use_oauth:
|
|
114
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
|
+
)
|
|
115
128
|
cloud_context = await anyio.to_thread.run_sync(
|
|
116
129
|
self._resolve_oauth_or_legacy_credential,
|
|
117
130
|
auth_value,
|
|
@@ -138,7 +151,14 @@ class CloudMcpAuthApp:
|
|
|
138
151
|
)
|
|
139
152
|
return
|
|
140
153
|
|
|
141
|
-
|
|
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:
|
|
142
162
|
call_platform = await anyio.to_thread.run_sync(
|
|
143
163
|
self._broker.resolve_call_platform,
|
|
144
164
|
cloud_context.token,
|