@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
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.43
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.43 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -55,7 +55,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
55
55
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
58
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.43 qingflow-skills install --agent codex --scope user --copy
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -114,7 +114,7 @@ qingflow --json task action \
|
|
|
114
114
|
- **定位只认 `task_id`**:来自 `task list` 的 `data.items[].task_id`;`app_key`、`record_id`、`workflow_node_id` 不是动作执行入参。
|
|
115
115
|
- `**approve` / `reject`**:执行上下文以当前待办详情或用户/前端已给出的显式 `formId` 为准;只有缺少 `formId` 时才兜底读取应用 baseInfo。不要把应用 baseInfo 的 `40002` 当作待办动作最终无权结论,最终是否可执行以后端审批动作接口为准。
|
|
116
116
|
- `**save_only`**:必须 提供非空 `**--fields-file`**(或 payload 与 fields 不能同时塞 `answers` 与 fields,具体以报错为准)。以当前待办节点的 `data.editable_fields` / `editableQueIds` 为准;若 `editableQueIds` 被当前权限链路挡住,但任务详情里的 `queAuthSetting` 已暴露可编辑字段,CLI 会按任务详情继续允许 save-only,并由最终保存接口裁决。申请表单 schema 或辅助 `editableQueIds` 的 `40002` 不是待办字段保存的最终无权结论。
|
|
117
|
-
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId
|
|
117
|
+
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId`);不能转给当前登录人。未提供 `payload.answers` 且没有通过 `--fields-file` 修改字段时,CLI 会先读取当前待办详情,并将已有 `answers` 原样带入转交请求;显式 `payload.answers` 或 `--fields-file` 合并结果优先。
|
|
118
118
|
- **意见必填**:若详情里 `**action_constraints.feedback_required_for`** 包含当前动作,需在 `**payload.audit_feedback`**(实现从 payload 抽取审计意见,具体键名以 `--help` / 服务端为准)中提供,否则会 config 错误。
|
|
119
119
|
- `**urge`**:**不支持** 与 `fields` 同时提交。
|
|
120
120
|
- **禁止臆测动作**:只允许 `**data.available_actions`** 中出现的动作;`approve` 不在列表则不可硬放行。
|
|
@@ -116,7 +116,7 @@ qingflow --json task action \
|
|
|
116
116
|
- **定位只认 `task_id`**:来自 `task list` 的 `data.items[].task_id`;`app_key`、`record_id`、`workflow_node_id` 不是动作执行入参。
|
|
117
117
|
- `**approve` / `reject`**:执行上下文以当前待办详情或用户/前端已给出的显式 `formId` 为准;只有缺少 `formId` 时才兜底读取应用 baseInfo。不要把应用 baseInfo 的 `40002` 当作待办动作最终无权结论,最终是否可执行以后端审批动作接口为准。
|
|
118
118
|
- `**save_only`**:必须 提供非空 `**--fields-file`**(或 payload 与 fields 不能同时塞 `answers` 与 fields,具体以报错为准)。以当前待办节点的 `data.editable_fields` / `editableQueIds` 为准;若 `editableQueIds` 被当前权限链路挡住,但任务详情里的 `queAuthSetting` 已暴露可编辑字段,CLI 会按任务详情继续允许 save-only,并由最终保存接口裁决。申请表单 schema 或辅助 `editableQueIds` 的 `40002` 不是待办字段保存的最终无权结论。
|
|
119
|
-
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId
|
|
119
|
+
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId`);不能转给当前登录人。未提供 `payload.answers` 且没有通过 `--fields-file` 修改字段时,CLI 会先读取当前待办详情,并将已有 `answers` 原样带入转交请求;显式 `payload.answers` 或 `--fields-file` 合并结果优先。
|
|
120
120
|
- **意见必填**:若详情里 `**action_constraints.feedback_required_for`** 包含当前动作,需在 `**payload.audit_feedback`**(实现从 payload 抽取审计意见,具体键名以 `--help` / 服务端为准)中提供,否则会 config 错误。
|
|
121
121
|
- `**urge`**:**不支持** 与 `fields` 同时提交。
|
|
122
122
|
- **禁止臆测动作**:只允许 `**data.available_actions`** 中出现的动作;`approve` 不在列表则不可硬放行。
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from .errors import QingflowApiError
|
|
7
|
+
from .oauth_profile_store import OAuthProfileState, OAuthProfileStore
|
|
8
|
+
from .oauth_token_store import OAuthTokenBundle, OAuthTokenStore
|
|
9
|
+
from .session_store import BackendSession, SessionProfile, SessionStore
|
|
10
|
+
from .cli.oauth_client import CLI_OAUTH_CLIENT_ID, CLI_OAUTH_SOURCE_CODE, OAuthClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
OAUTH_REFRESH_SKEW = timedelta(seconds=90)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OAuthSessionProvider:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
profile_store: OAuthProfileStore,
|
|
20
|
+
token_store: OAuthTokenStore,
|
|
21
|
+
oauth_client: OAuthClient,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.profile_store = profile_store
|
|
24
|
+
self.token_store = token_store
|
|
25
|
+
self.oauth_client = oauth_client
|
|
26
|
+
|
|
27
|
+
def get_state(self, profile: str) -> OAuthProfileState | None:
|
|
28
|
+
return self.profile_store.get(profile)
|
|
29
|
+
|
|
30
|
+
def get_profile(self, profile: str, *, refresh: bool = True) -> SessionProfile:
|
|
31
|
+
state, bundle = self._resolve(profile, refresh=refresh)
|
|
32
|
+
return self._session_profile(state, token=bundle.access_token if refresh else None)
|
|
33
|
+
|
|
34
|
+
def get_backend_session(self, profile: str) -> BackendSession:
|
|
35
|
+
state, bundle = self._resolve(profile, refresh=True)
|
|
36
|
+
return BackendSession(
|
|
37
|
+
token=bundle.access_token,
|
|
38
|
+
login_token=None,
|
|
39
|
+
credential=None,
|
|
40
|
+
profile=profile,
|
|
41
|
+
base_url=state.base_url or "",
|
|
42
|
+
qf_version=state.qf_version,
|
|
43
|
+
qf_version_source=state.qf_version_source,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def activate(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
profile: str,
|
|
50
|
+
base_url: str,
|
|
51
|
+
qf_version: str | None,
|
|
52
|
+
qf_version_source: str | None,
|
|
53
|
+
uid: int,
|
|
54
|
+
email: str | None,
|
|
55
|
+
nick_name: str | None,
|
|
56
|
+
selected_ws_id: int | None,
|
|
57
|
+
selected_ws_name: str | None,
|
|
58
|
+
issuer: str,
|
|
59
|
+
resource: str,
|
|
60
|
+
bundle: OAuthTokenBundle,
|
|
61
|
+
) -> OAuthProfileState:
|
|
62
|
+
with self.profile_store.lock(profile):
|
|
63
|
+
previous_state = self.profile_store.get(profile)
|
|
64
|
+
previous_bundle = self.token_store.load(profile)
|
|
65
|
+
previous_storage = (
|
|
66
|
+
previous_state.secret_storage
|
|
67
|
+
if previous_state is not None and previous_state.active_auth_type == "oauth"
|
|
68
|
+
else None
|
|
69
|
+
)
|
|
70
|
+
storage = self.token_store.save(profile, bundle, storage=previous_storage, locked=True)
|
|
71
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
72
|
+
state = OAuthProfileState(
|
|
73
|
+
active_auth_type="oauth",
|
|
74
|
+
profile=profile,
|
|
75
|
+
base_url=base_url,
|
|
76
|
+
qf_version=qf_version,
|
|
77
|
+
qf_version_source=qf_version_source,
|
|
78
|
+
uid=uid,
|
|
79
|
+
email=email,
|
|
80
|
+
nick_name=nick_name,
|
|
81
|
+
selected_ws_id=selected_ws_id,
|
|
82
|
+
selected_ws_name=selected_ws_name,
|
|
83
|
+
persisted=True,
|
|
84
|
+
secret_storage=storage,
|
|
85
|
+
issuer=issuer,
|
|
86
|
+
client_id=CLI_OAUTH_CLIENT_ID,
|
|
87
|
+
resource=resource,
|
|
88
|
+
source_code=CLI_OAUTH_SOURCE_CODE,
|
|
89
|
+
access_expires_at=bundle.access_expires_at,
|
|
90
|
+
created_at=previous_state.created_at if previous_state is not None else now,
|
|
91
|
+
updated_at=now,
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
self.profile_store.save(state, locked=True)
|
|
95
|
+
except Exception:
|
|
96
|
+
if previous_bundle is None:
|
|
97
|
+
self.token_store.delete(profile, locked=True)
|
|
98
|
+
else:
|
|
99
|
+
self.token_store.save(
|
|
100
|
+
profile,
|
|
101
|
+
previous_bundle,
|
|
102
|
+
storage=previous_storage,
|
|
103
|
+
locked=True,
|
|
104
|
+
)
|
|
105
|
+
if previous_state is None:
|
|
106
|
+
self.profile_store.delete(profile, locked=True)
|
|
107
|
+
else:
|
|
108
|
+
self.profile_store.save(previous_state, locked=True)
|
|
109
|
+
raise
|
|
110
|
+
return state
|
|
111
|
+
|
|
112
|
+
def refresh(self, profile: str, *, force: bool, observed_generation: int | None = None) -> bool:
|
|
113
|
+
with self.profile_store.lock(profile):
|
|
114
|
+
state = self._require_oauth_state(profile)
|
|
115
|
+
bundle = self._require_bundle(profile)
|
|
116
|
+
if observed_generation is not None and bundle.generation > observed_generation:
|
|
117
|
+
return True
|
|
118
|
+
if not force and not self._needs_refresh(bundle):
|
|
119
|
+
return True
|
|
120
|
+
metadata = self.oauth_client.discover(state.base_url or "")
|
|
121
|
+
try:
|
|
122
|
+
refreshed = self.oauth_client.refresh(
|
|
123
|
+
metadata,
|
|
124
|
+
refresh_token=bundle.refresh_token,
|
|
125
|
+
generation=bundle.generation,
|
|
126
|
+
)
|
|
127
|
+
except QingflowApiError as error:
|
|
128
|
+
oauth_error = (error.details or {}).get("oauth_error")
|
|
129
|
+
failure = "invalid_grant" if oauth_error == "invalid_grant" else "uncertain_result"
|
|
130
|
+
self._invalidate_refresh_bundle(profile)
|
|
131
|
+
details = {"reason": "oauth_relogin_required", "refresh_failure": failure}
|
|
132
|
+
if oauth_error:
|
|
133
|
+
details["oauth_error"] = oauth_error
|
|
134
|
+
raise QingflowApiError(
|
|
135
|
+
category="auth",
|
|
136
|
+
message=(
|
|
137
|
+
f"OAuth session for profile '{profile}' could not be safely refreshed. "
|
|
138
|
+
"Run auth login --method oauth again."
|
|
139
|
+
),
|
|
140
|
+
backend_code=error.backend_code,
|
|
141
|
+
request_id=error.request_id,
|
|
142
|
+
http_status=error.http_status,
|
|
143
|
+
details=details,
|
|
144
|
+
) from error
|
|
145
|
+
try:
|
|
146
|
+
storage = self.token_store.save(
|
|
147
|
+
profile,
|
|
148
|
+
refreshed,
|
|
149
|
+
storage=state.secret_storage,
|
|
150
|
+
locked=True,
|
|
151
|
+
)
|
|
152
|
+
state.secret_storage = storage
|
|
153
|
+
state.access_expires_at = refreshed.access_expires_at
|
|
154
|
+
self.profile_store.save(state, locked=True)
|
|
155
|
+
except Exception as error:
|
|
156
|
+
self._invalidate_refresh_bundle(profile)
|
|
157
|
+
raise QingflowApiError(
|
|
158
|
+
category="auth",
|
|
159
|
+
message=(
|
|
160
|
+
f"OAuth session for profile '{profile}' could not persist its refreshed token. "
|
|
161
|
+
"Run auth login --method oauth again."
|
|
162
|
+
),
|
|
163
|
+
details={
|
|
164
|
+
"reason": "oauth_relogin_required",
|
|
165
|
+
"refresh_failure": "local_persistence_failed",
|
|
166
|
+
},
|
|
167
|
+
) from error
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def _invalidate_refresh_bundle(self, profile: str) -> None:
|
|
171
|
+
try:
|
|
172
|
+
self.token_store.delete(profile, locked=True)
|
|
173
|
+
except QingflowApiError:
|
|
174
|
+
pass
|
|
175
|
+
self.profile_store.set_none(profile, locked=True)
|
|
176
|
+
|
|
177
|
+
def update_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
|
|
178
|
+
with self.profile_store.lock(profile):
|
|
179
|
+
state = self._require_oauth_state(profile)
|
|
180
|
+
state.selected_ws_id = ws_id
|
|
181
|
+
state.selected_ws_name = ws_name
|
|
182
|
+
self.profile_store.save(state, locked=True)
|
|
183
|
+
return self.get_profile(profile, refresh=False)
|
|
184
|
+
|
|
185
|
+
def update_route(
|
|
186
|
+
self,
|
|
187
|
+
profile: str,
|
|
188
|
+
*,
|
|
189
|
+
qf_version: str | None,
|
|
190
|
+
qf_version_source: str | None,
|
|
191
|
+
) -> SessionProfile:
|
|
192
|
+
with self.profile_store.lock(profile):
|
|
193
|
+
state = self._require_oauth_state(profile)
|
|
194
|
+
state.qf_version = _text(qf_version)
|
|
195
|
+
state.qf_version_source = _text(qf_version_source)
|
|
196
|
+
self.profile_store.save(state, locked=True)
|
|
197
|
+
return self.get_profile(profile, refresh=False)
|
|
198
|
+
|
|
199
|
+
def update_metadata(
|
|
200
|
+
self,
|
|
201
|
+
profile: str,
|
|
202
|
+
*,
|
|
203
|
+
uid: int | object,
|
|
204
|
+
email: str | None | object,
|
|
205
|
+
nick_name: str | None | object,
|
|
206
|
+
selected_ws_id: int | None | object,
|
|
207
|
+
selected_ws_name: str | None | object,
|
|
208
|
+
unset: object,
|
|
209
|
+
) -> SessionProfile:
|
|
210
|
+
with self.profile_store.lock(profile):
|
|
211
|
+
state = self._require_oauth_state(profile)
|
|
212
|
+
if uid is not unset:
|
|
213
|
+
state.uid = int(uid)
|
|
214
|
+
if email is not unset:
|
|
215
|
+
state.email = email if isinstance(email, str) or email is None else state.email
|
|
216
|
+
if nick_name is not unset:
|
|
217
|
+
state.nick_name = nick_name if isinstance(nick_name, str) or nick_name is None else state.nick_name
|
|
218
|
+
if selected_ws_id is not unset:
|
|
219
|
+
state.selected_ws_id = (
|
|
220
|
+
int(selected_ws_id)
|
|
221
|
+
if isinstance(selected_ws_id, int) and not isinstance(selected_ws_id, bool)
|
|
222
|
+
else None
|
|
223
|
+
)
|
|
224
|
+
if selected_ws_name is not unset:
|
|
225
|
+
state.selected_ws_name = (
|
|
226
|
+
selected_ws_name
|
|
227
|
+
if isinstance(selected_ws_name, str) or selected_ws_name is None
|
|
228
|
+
else state.selected_ws_name
|
|
229
|
+
)
|
|
230
|
+
self.profile_store.save(state, locked=True)
|
|
231
|
+
return self.get_profile(profile, refresh=False)
|
|
232
|
+
|
|
233
|
+
def logout(self, profile: str) -> list[dict[str, str]]:
|
|
234
|
+
with self.profile_store.lock(profile):
|
|
235
|
+
state = self._require_oauth_state(profile)
|
|
236
|
+
warnings: list[dict[str, str]] = []
|
|
237
|
+
bundle = self.token_store.load(profile)
|
|
238
|
+
if bundle is not None:
|
|
239
|
+
try:
|
|
240
|
+
self.oauth_client.revoke(state.base_url or "", bundle.refresh_token)
|
|
241
|
+
except QingflowApiError as error:
|
|
242
|
+
warnings.append(
|
|
243
|
+
{
|
|
244
|
+
"code": "OAUTH_REMOTE_REVOCATION_FAILED",
|
|
245
|
+
"message": error.message,
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
self.token_store.delete(profile, locked=True)
|
|
249
|
+
self.profile_store.set_none(profile, locked=True)
|
|
250
|
+
return warnings
|
|
251
|
+
|
|
252
|
+
def deactivate_for_legacy(self, profile: str) -> list[dict[str, str]]:
|
|
253
|
+
with self.profile_store.lock(profile):
|
|
254
|
+
state = self.get_state(profile)
|
|
255
|
+
if state is None:
|
|
256
|
+
return []
|
|
257
|
+
warnings: list[dict[str, str]] = []
|
|
258
|
+
if state.active_auth_type == "oauth":
|
|
259
|
+
bundle = self.token_store.load(profile)
|
|
260
|
+
if bundle is not None:
|
|
261
|
+
try:
|
|
262
|
+
self.oauth_client.revoke(state.base_url or "", bundle.refresh_token)
|
|
263
|
+
except QingflowApiError as error:
|
|
264
|
+
warnings.append(
|
|
265
|
+
{
|
|
266
|
+
"code": "OAUTH_REMOTE_REVOCATION_FAILED",
|
|
267
|
+
"message": error.message,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
self.token_store.delete(profile, locked=True)
|
|
271
|
+
self.profile_store.delete(profile, locked=True)
|
|
272
|
+
return warnings
|
|
273
|
+
|
|
274
|
+
def _resolve(self, profile: str, *, refresh: bool) -> tuple[OAuthProfileState, OAuthTokenBundle]:
|
|
275
|
+
state = self._require_oauth_state(profile)
|
|
276
|
+
bundle = self._require_bundle(profile)
|
|
277
|
+
if refresh and self._needs_refresh(bundle):
|
|
278
|
+
try:
|
|
279
|
+
self.refresh(profile, force=False, observed_generation=bundle.generation)
|
|
280
|
+
state = self._require_oauth_state(profile)
|
|
281
|
+
bundle = self._require_bundle(profile)
|
|
282
|
+
except QingflowApiError as error:
|
|
283
|
+
if error.category != "network" or self._is_expired(bundle):
|
|
284
|
+
raise
|
|
285
|
+
return state, bundle
|
|
286
|
+
|
|
287
|
+
def _require_oauth_state(self, profile: str) -> OAuthProfileState:
|
|
288
|
+
state = self.get_state(profile)
|
|
289
|
+
if state is None or state.active_auth_type != "oauth":
|
|
290
|
+
raise QingflowApiError.auth_required(profile)
|
|
291
|
+
return state
|
|
292
|
+
|
|
293
|
+
def _require_bundle(self, profile: str) -> OAuthTokenBundle:
|
|
294
|
+
bundle = self.token_store.load(profile)
|
|
295
|
+
if bundle is None:
|
|
296
|
+
raise QingflowApiError(
|
|
297
|
+
category="auth",
|
|
298
|
+
message=(
|
|
299
|
+
f"OAuth session for profile '{profile}' is missing its token bundle. "
|
|
300
|
+
"Run auth login --method oauth again."
|
|
301
|
+
),
|
|
302
|
+
details={"reason": "oauth_relogin_required"},
|
|
303
|
+
)
|
|
304
|
+
return bundle
|
|
305
|
+
|
|
306
|
+
def _session_profile(self, state: OAuthProfileState, *, token: str | None) -> SessionProfile:
|
|
307
|
+
return SessionProfile(
|
|
308
|
+
profile=state.profile,
|
|
309
|
+
base_url=state.base_url or "",
|
|
310
|
+
qf_version=state.qf_version,
|
|
311
|
+
qf_version_source=state.qf_version_source,
|
|
312
|
+
token=token,
|
|
313
|
+
login_token=None,
|
|
314
|
+
credential=None,
|
|
315
|
+
uid=int(state.uid or 0),
|
|
316
|
+
email=state.email,
|
|
317
|
+
nick_name=state.nick_name,
|
|
318
|
+
selected_ws_id=state.selected_ws_id,
|
|
319
|
+
selected_ws_name=state.selected_ws_name,
|
|
320
|
+
persisted=True,
|
|
321
|
+
created_at=state.created_at,
|
|
322
|
+
updated_at=state.updated_at,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _needs_refresh(self, bundle: OAuthTokenBundle) -> bool:
|
|
326
|
+
return self._expires_at(bundle) <= datetime.now(timezone.utc) + OAUTH_REFRESH_SKEW
|
|
327
|
+
|
|
328
|
+
def _is_expired(self, bundle: OAuthTokenBundle) -> bool:
|
|
329
|
+
return self._expires_at(bundle) <= datetime.now(timezone.utc)
|
|
330
|
+
|
|
331
|
+
def _expires_at(self, bundle: OAuthTokenBundle) -> datetime:
|
|
332
|
+
try:
|
|
333
|
+
value = datetime.fromisoformat(bundle.access_expires_at)
|
|
334
|
+
except ValueError as exc:
|
|
335
|
+
raise QingflowApiError.config_error("OAuth access_expires_at is invalid.") from exc
|
|
336
|
+
if value.tzinfo is None:
|
|
337
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
338
|
+
return value.astimezone(timezone.utc)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class CliSessionFacade:
|
|
342
|
+
def __init__(self, legacy: SessionStore, oauth: OAuthSessionProvider) -> None:
|
|
343
|
+
self.legacy = legacy
|
|
344
|
+
self.oauth = oauth
|
|
345
|
+
self._pending_legacy_profiles: set[str] = set()
|
|
346
|
+
|
|
347
|
+
def set_profile_refresher(self, refresher: Callable[[str, SessionProfile | None], bool] | None) -> None:
|
|
348
|
+
self.legacy.set_profile_refresher(refresher)
|
|
349
|
+
|
|
350
|
+
def oauth_state(self, profile: str) -> str | None:
|
|
351
|
+
state = self.oauth.get_state(profile)
|
|
352
|
+
return state.active_auth_type if state is not None else None
|
|
353
|
+
|
|
354
|
+
def uses_oauth(self, profile: str) -> bool:
|
|
355
|
+
return profile not in self._pending_legacy_profiles and self.oauth_state(profile) == "oauth"
|
|
356
|
+
|
|
357
|
+
def blocks_legacy(self, profile: str) -> bool:
|
|
358
|
+
return profile not in self._pending_legacy_profiles and self.oauth_state(profile) in {"oauth", "none"}
|
|
359
|
+
|
|
360
|
+
def save_session(self, **kwargs) -> SessionProfile: # type: ignore[no-untyped-def]
|
|
361
|
+
profile = str(kwargs.get("profile") or "")
|
|
362
|
+
oauth_state = self.oauth_state(profile)
|
|
363
|
+
saved = self.legacy.save_session(**kwargs)
|
|
364
|
+
if oauth_state in {"oauth", "none"}:
|
|
365
|
+
self._pending_legacy_profiles.add(profile)
|
|
366
|
+
return saved
|
|
367
|
+
|
|
368
|
+
def get_profile(self, profile: str) -> SessionProfile | None:
|
|
369
|
+
if profile in self._pending_legacy_profiles:
|
|
370
|
+
return self.legacy.get_profile(profile)
|
|
371
|
+
state = self.oauth_state(profile)
|
|
372
|
+
if state == "oauth":
|
|
373
|
+
return self.oauth.get_profile(profile)
|
|
374
|
+
if state == "none":
|
|
375
|
+
return None
|
|
376
|
+
return self.legacy.get_profile(profile)
|
|
377
|
+
|
|
378
|
+
def peek_profile(self, profile: str) -> SessionProfile | None:
|
|
379
|
+
if profile in self._pending_legacy_profiles:
|
|
380
|
+
return self.legacy.peek_profile(profile)
|
|
381
|
+
state = self.oauth_state(profile)
|
|
382
|
+
if state == "oauth":
|
|
383
|
+
return self.oauth.get_profile(profile, refresh=False)
|
|
384
|
+
if state == "none":
|
|
385
|
+
return None
|
|
386
|
+
return self.legacy.peek_profile(profile)
|
|
387
|
+
|
|
388
|
+
def get_backend_session(self, profile: str) -> BackendSession | None:
|
|
389
|
+
if profile in self._pending_legacy_profiles:
|
|
390
|
+
return self.legacy.get_backend_session(profile)
|
|
391
|
+
state = self.oauth_state(profile)
|
|
392
|
+
if state == "oauth":
|
|
393
|
+
return self.oauth.get_backend_session(profile)
|
|
394
|
+
if state == "none":
|
|
395
|
+
return None
|
|
396
|
+
return self.legacy.get_backend_session(profile)
|
|
397
|
+
|
|
398
|
+
def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
|
|
399
|
+
if self.uses_oauth(profile):
|
|
400
|
+
return self.oauth.update_workspace(profile, ws_id, ws_name)
|
|
401
|
+
if self.blocks_legacy(profile):
|
|
402
|
+
raise KeyError(profile)
|
|
403
|
+
return self.legacy.select_workspace(profile, ws_id, ws_name)
|
|
404
|
+
|
|
405
|
+
def update_route(
|
|
406
|
+
self,
|
|
407
|
+
profile: str,
|
|
408
|
+
*,
|
|
409
|
+
qf_version: str | None,
|
|
410
|
+
qf_version_source: str | None,
|
|
411
|
+
) -> SessionProfile:
|
|
412
|
+
if self.uses_oauth(profile):
|
|
413
|
+
return self.oauth.update_route(
|
|
414
|
+
profile,
|
|
415
|
+
qf_version=qf_version,
|
|
416
|
+
qf_version_source=qf_version_source,
|
|
417
|
+
)
|
|
418
|
+
if self.blocks_legacy(profile):
|
|
419
|
+
raise KeyError(profile)
|
|
420
|
+
return self.legacy.update_route(
|
|
421
|
+
profile,
|
|
422
|
+
qf_version=qf_version,
|
|
423
|
+
qf_version_source=qf_version_source,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def update_profile_metadata(self, profile: str, **kwargs) -> SessionProfile: # type: ignore[no-untyped-def]
|
|
427
|
+
if self.uses_oauth(profile):
|
|
428
|
+
unset = object()
|
|
429
|
+
return self.oauth.update_metadata(
|
|
430
|
+
profile,
|
|
431
|
+
uid=kwargs.get("uid", unset),
|
|
432
|
+
email=kwargs.get("email", unset),
|
|
433
|
+
nick_name=kwargs.get("nick_name", unset),
|
|
434
|
+
selected_ws_id=kwargs.get("selected_ws_id", unset),
|
|
435
|
+
selected_ws_name=kwargs.get("selected_ws_name", unset),
|
|
436
|
+
unset=unset,
|
|
437
|
+
)
|
|
438
|
+
if self.blocks_legacy(profile):
|
|
439
|
+
raise KeyError(profile)
|
|
440
|
+
return self.legacy.update_profile_metadata(profile, **kwargs)
|
|
441
|
+
|
|
442
|
+
def logout(self, profile: str, forget_persisted: bool = False) -> None:
|
|
443
|
+
if self.uses_oauth(profile):
|
|
444
|
+
self.oauth.logout(profile)
|
|
445
|
+
return
|
|
446
|
+
if self.oauth_state(profile) == "none":
|
|
447
|
+
return
|
|
448
|
+
self.legacy.logout(profile, forget_persisted=forget_persisted)
|
|
449
|
+
|
|
450
|
+
def invalidate(self, profile: str) -> None:
|
|
451
|
+
if self.uses_oauth(profile):
|
|
452
|
+
self.oauth.token_store.delete(profile)
|
|
453
|
+
return
|
|
454
|
+
if self.oauth_state(profile) == "none":
|
|
455
|
+
return
|
|
456
|
+
self.legacy.invalidate(profile)
|
|
457
|
+
|
|
458
|
+
def has_profile(self, profile: str) -> bool:
|
|
459
|
+
if self.uses_oauth(profile):
|
|
460
|
+
return True
|
|
461
|
+
if self.oauth_state(profile) == "none":
|
|
462
|
+
return False
|
|
463
|
+
return self.legacy.has_profile(profile)
|
|
464
|
+
|
|
465
|
+
def activate_oauth(self, **kwargs) -> OAuthProfileState: # type: ignore[no-untyped-def]
|
|
466
|
+
profile = str(kwargs.get("profile") or "")
|
|
467
|
+
state = self.oauth.activate(**kwargs)
|
|
468
|
+
self._pending_legacy_profiles.discard(profile)
|
|
469
|
+
return state
|
|
470
|
+
|
|
471
|
+
def complete_legacy_login(self, profile: str) -> list[dict[str, str]]:
|
|
472
|
+
warnings = self.oauth.deactivate_for_legacy(profile)
|
|
473
|
+
self._pending_legacy_profiles.discard(profile)
|
|
474
|
+
return warnings
|
|
475
|
+
|
|
476
|
+
def begin_legacy_login(self, profile: str) -> None:
|
|
477
|
+
self._pending_legacy_profiles.add(profile)
|
|
478
|
+
|
|
479
|
+
def cancel_legacy_login(self, profile: str) -> None:
|
|
480
|
+
self._pending_legacy_profiles.discard(profile)
|
|
481
|
+
|
|
482
|
+
def oauth_logout(self, profile: str) -> list[dict[str, str]]:
|
|
483
|
+
self._pending_legacy_profiles.discard(profile)
|
|
484
|
+
return self.oauth.logout(profile)
|
|
485
|
+
|
|
486
|
+
def refresh_oauth_session(self, profile: str, *, force: bool = True) -> bool:
|
|
487
|
+
if not self.uses_oauth(profile):
|
|
488
|
+
return False
|
|
489
|
+
backend = self.oauth.token_store.load(profile)
|
|
490
|
+
generation = backend.generation if backend is not None else None
|
|
491
|
+
return self.oauth.refresh(profile, force=force, observed_generation=generation)
|
|
492
|
+
|
|
493
|
+
def active_auth_type(self, profile: str) -> str:
|
|
494
|
+
if self.uses_oauth(profile):
|
|
495
|
+
return "oauth"
|
|
496
|
+
if self.oauth_state(profile) == "none":
|
|
497
|
+
return "none"
|
|
498
|
+
session_profile = self.legacy.peek_profile(profile)
|
|
499
|
+
if session_profile is None:
|
|
500
|
+
return "none"
|
|
501
|
+
return "credential" if _text(session_profile.credential) else "legacy_token"
|
|
502
|
+
|
|
503
|
+
def oauth_details(self, profile: str) -> dict[str, object]:
|
|
504
|
+
state = self.oauth.get_state(profile)
|
|
505
|
+
if state is None or state.active_auth_type != "oauth":
|
|
506
|
+
return {}
|
|
507
|
+
return {
|
|
508
|
+
"active_auth_type": "oauth",
|
|
509
|
+
"auth_source": "oauth_pkce",
|
|
510
|
+
"source_code": state.source_code,
|
|
511
|
+
"access_expires_at": state.access_expires_at,
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
def close(self) -> None:
|
|
515
|
+
self.oauth.oauth_client.close()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _text(value: object) -> str | None:
|
|
519
|
+
if value is None:
|
|
520
|
+
return None
|
|
521
|
+
normalized = str(value).strip()
|
|
522
|
+
return normalized or None
|