cheso-cli 0.2.0__py3-none-any.whl
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.
- cheso_cli/__init__.py +12 -0
- cheso_cli/__main__.py +5 -0
- cheso_cli/auth.py +128 -0
- cheso_cli/cli.py +864 -0
- cheso_cli/client.py +534 -0
- cheso_cli/config.py +108 -0
- cheso_cli/credentials.py +235 -0
- cheso_cli/errors.py +56 -0
- cheso_cli/legacy_commands.py +93 -0
- cheso_cli/oauth.py +797 -0
- cheso_cli/skill/SKILL.md +83 -0
- cheso_cli/skill_install.py +119 -0
- cheso_cli/tasks.py +834 -0
- cheso_cli/transfer.py +294 -0
- cheso_cli-0.2.0.dist-info/METADATA +164 -0
- cheso_cli-0.2.0.dist-info/RECORD +18 -0
- cheso_cli-0.2.0.dist-info/WHEEL +4 -0
- cheso_cli-0.2.0.dist-info/entry_points.txt +4 -0
cheso_cli/oauth.py
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
"""Cheso 授权服务器的 OAuth 2.1 客户端。
|
|
2
|
+
|
|
3
|
+
支持 RFC 8414 元数据、RFC 7591 注册、RFC 8252 回环重定向的授权码 + PKCE S256 登录、
|
|
4
|
+
RFC 9207 ``iss`` 校验和 RFC 7009 撤销;没有浏览器或无法访问回环回调时,支持 RFC 8628 设备授权码流程。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import html
|
|
13
|
+
import http.server
|
|
14
|
+
import math
|
|
15
|
+
import secrets
|
|
16
|
+
import time
|
|
17
|
+
import webbrowser
|
|
18
|
+
from collections.abc import Callable, Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, NoReturn, TextIO
|
|
21
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from cheso_cli.client import Deadline, TimeLimitReached, call_until
|
|
26
|
+
from cheso_cli.config import Settings
|
|
27
|
+
from cheso_cli.credentials import CredentialStore, SiteCredentials
|
|
28
|
+
from cheso_cli.errors import CliError, UsageError
|
|
29
|
+
|
|
30
|
+
WELL_KNOWN_PATH = "/.well-known/oauth-authorization-server"
|
|
31
|
+
SCOPE = "cheso"
|
|
32
|
+
CLIENT_NAME = "Cheso CLI"
|
|
33
|
+
LOOPBACK_HOST = "127.0.0.1"
|
|
34
|
+
CALLBACK_PATH = "/callback"
|
|
35
|
+
# 只注册一次;回环重定向 URI 可以匹配任意端口,见 RFC 8252 §7.3。
|
|
36
|
+
REGISTERED_REDIRECT_URI = f"http://{LOOPBACK_HOST}{CALLBACK_PATH}"
|
|
37
|
+
DEFAULT_LOGIN_TIMEOUT = 300.0
|
|
38
|
+
HTTP_TIMEOUT = 30.0
|
|
39
|
+
|
|
40
|
+
ERROR_INVALID_GRANT = "invalid_grant"
|
|
41
|
+
ERROR_INVALID_CLIENT = "invalid_client"
|
|
42
|
+
|
|
43
|
+
# 设备授权码流程,见 RFC 8628。
|
|
44
|
+
DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
45
|
+
DEFAULT_DEVICE_INTERVAL = 5.0
|
|
46
|
+
SLOW_DOWN_STEP = 5.0
|
|
47
|
+
# 连接或读取超时后,将下次轮询前的等待时间翻倍,最多增至以下秒数,见 RFC 8628 §3.5。
|
|
48
|
+
MAX_BACKOFF_INTERVAL = 60.0
|
|
49
|
+
# 登录成功后始终保存凭据;撤销被替换的旧登录和查询账号信息时,
|
|
50
|
+
# 允许在 --timeout 之后额外运行以下时长。
|
|
51
|
+
AFTER_LOGIN_GRACE = 5.0
|
|
52
|
+
ERROR_AUTHORIZATION_PENDING = "authorization_pending"
|
|
53
|
+
ERROR_SLOW_DOWN = "slow_down"
|
|
54
|
+
ERROR_ACCESS_DENIED = "access_denied"
|
|
55
|
+
ERROR_EXPIRED_TOKEN = "expired_token"
|
|
56
|
+
KIND_LOGIN_DENIED = "login_denied"
|
|
57
|
+
KIND_LOGIN_EXPIRED = "login_expired"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OAuthTimeout(CliError):
|
|
61
|
+
"""连接或读取授权服务器时发生超时;轮询遇到此错误会增加退避等待。"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OAuthProtocolError(CliError):
|
|
65
|
+
"""授权服务器返回 RFC 6749 §5.2 定义的错误。"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, error: str, description: str = "", *, http_status: int | None = None) -> None:
|
|
68
|
+
text = f"{error}: {description}" if description else error
|
|
69
|
+
super().__init__(f"authorization server error: {text}", kind="oauth_error", extra={"oauth_error": error})
|
|
70
|
+
self.error = error
|
|
71
|
+
self.description = description
|
|
72
|
+
self.http_status = http_status
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ServerMetadata:
|
|
77
|
+
issuer: str
|
|
78
|
+
authorization_endpoint: str
|
|
79
|
+
token_endpoint: str
|
|
80
|
+
registration_endpoint: str
|
|
81
|
+
revocation_endpoint: str
|
|
82
|
+
device_authorization_endpoint: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class DeviceLogin:
|
|
87
|
+
"""等待用户确认的设备码登录信息,包括展示内容和轮询间隔。"""
|
|
88
|
+
|
|
89
|
+
verification_uri: str
|
|
90
|
+
user_code: str
|
|
91
|
+
expires_at: float
|
|
92
|
+
interval: float
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class TokenSet:
|
|
97
|
+
access_token: str
|
|
98
|
+
refresh_token: str
|
|
99
|
+
expires_at: float
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pkce_pair() -> tuple[str, str]:
|
|
103
|
+
"""生成 S256 的 ``(code_verifier, code_challenge)``:86 个 URL 安全字符和 base64url(sha256(verifier))。"""
|
|
104
|
+
verifier = secrets.token_urlsafe(64)
|
|
105
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
106
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
107
|
+
return verifier, challenge
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
111
|
+
parts = urlsplit(url)
|
|
112
|
+
return parts.scheme, (parts.hostname or "").lower(), parts.port
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class OAuthClient:
|
|
116
|
+
"""访问 OAuth 协议端点;表单通过 application/x-www-form-urlencoded 发送,不放进 URL。"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
settings: Settings,
|
|
121
|
+
*,
|
|
122
|
+
transport: httpx.BaseTransport | None = None,
|
|
123
|
+
now: Callable[[], float] = time.time,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.settings = settings
|
|
126
|
+
self._now = now
|
|
127
|
+
headers = {"Accept": "application/json", **settings.extra_headers()}
|
|
128
|
+
self._http = httpx.Client(headers=headers, timeout=HTTP_TIMEOUT, transport=transport, follow_redirects=False)
|
|
129
|
+
self._deadline: Deadline | None = None
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
self._http.close()
|
|
133
|
+
|
|
134
|
+
def __enter__(self) -> OAuthClient:
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def __exit__(self, *args: object) -> None:
|
|
138
|
+
self.close()
|
|
139
|
+
|
|
140
|
+
@contextlib.contextmanager
|
|
141
|
+
def time_limit(self, deadline: Deadline) -> Iterator[None]:
|
|
142
|
+
"""作用域内不在 ``deadline`` 之后发起请求,等待完整响应也不能超过该时刻;
|
|
143
|
+
到期后改为抛出 TimeLimitReached。
|
|
144
|
+
"""
|
|
145
|
+
previous, self._deadline = self._deadline, deadline
|
|
146
|
+
try:
|
|
147
|
+
yield
|
|
148
|
+
finally:
|
|
149
|
+
self._deadline = previous
|
|
150
|
+
|
|
151
|
+
# ---- 发现服务与注册客户端 ----
|
|
152
|
+
|
|
153
|
+
def metadata(self) -> ServerMetadata:
|
|
154
|
+
base = self.settings.base_url
|
|
155
|
+
resp = self._send("GET", base + WELL_KNOWN_PATH, target=base)
|
|
156
|
+
if resp.status_code == 404:
|
|
157
|
+
raise CliError(f"{base} does not offer OAuth login yet; set CHESO_API_TOKEN instead")
|
|
158
|
+
raw = self._json(resp)
|
|
159
|
+
if resp.status_code != 200:
|
|
160
|
+
raise self._error(resp, raw)
|
|
161
|
+
meta = ServerMetadata(
|
|
162
|
+
issuer=str(raw.get("issuer") or "").rstrip("/"),
|
|
163
|
+
authorization_endpoint=str(raw.get("authorization_endpoint") or ""),
|
|
164
|
+
token_endpoint=str(raw.get("token_endpoint") or ""),
|
|
165
|
+
registration_endpoint=str(raw.get("registration_endpoint") or ""),
|
|
166
|
+
revocation_endpoint=str(raw.get("revocation_endpoint") or ""),
|
|
167
|
+
device_authorization_endpoint=str(raw.get("device_authorization_endpoint") or ""),
|
|
168
|
+
)
|
|
169
|
+
self._check_metadata(meta, raw)
|
|
170
|
+
return meta
|
|
171
|
+
|
|
172
|
+
def _check_metadata(self, meta: ServerMetadata, raw: dict[str, Any]) -> None:
|
|
173
|
+
base = self.settings.base_url
|
|
174
|
+
# RFC 8414 §3.3:issuer 必须与获取元数据时使用的 URL 一致。
|
|
175
|
+
if meta.issuer != base:
|
|
176
|
+
raise CliError(f"authorization server issuer {meta.issuer!r} does not match {base!r}; refusing to log in")
|
|
177
|
+
scheme, _, _ = _origin(base)
|
|
178
|
+
for name in ("authorization_endpoint", "token_endpoint", "registration_endpoint"):
|
|
179
|
+
value = getattr(meta, name)
|
|
180
|
+
if not value or _origin(value)[0] != scheme:
|
|
181
|
+
raise CliError(f"authorization server metadata has no usable {name}")
|
|
182
|
+
# 令牌和设备码只发送到 issuer 的同源地址。
|
|
183
|
+
for name in ("token_endpoint", "registration_endpoint", "revocation_endpoint", "device_authorization_endpoint"):
|
|
184
|
+
value = getattr(meta, name)
|
|
185
|
+
if value and _origin(value) != _origin(base):
|
|
186
|
+
raise CliError(f"authorization server {name} is not on {base}; refusing to log in")
|
|
187
|
+
methods = raw.get("code_challenge_methods_supported") or []
|
|
188
|
+
if "S256" not in methods:
|
|
189
|
+
raise CliError("authorization server does not support PKCE S256")
|
|
190
|
+
|
|
191
|
+
def register(self, meta: ServerMetadata) -> str:
|
|
192
|
+
body = {
|
|
193
|
+
"client_name": CLIENT_NAME,
|
|
194
|
+
"redirect_uris": [REGISTERED_REDIRECT_URI],
|
|
195
|
+
"grant_types": ["authorization_code", "refresh_token"],
|
|
196
|
+
"response_types": ["code"],
|
|
197
|
+
"token_endpoint_auth_method": "none",
|
|
198
|
+
}
|
|
199
|
+
resp = self._post(meta.registration_endpoint, json=body)
|
|
200
|
+
raw = self._json(resp)
|
|
201
|
+
if resp.status_code not in (200, 201):
|
|
202
|
+
raise self._error(resp, raw)
|
|
203
|
+
client_id = str(raw.get("client_id") or "")
|
|
204
|
+
if not client_id:
|
|
205
|
+
raise CliError("authorization server did not return a client_id")
|
|
206
|
+
return client_id
|
|
207
|
+
|
|
208
|
+
# ---- 令牌 ----
|
|
209
|
+
|
|
210
|
+
def exchange_code(
|
|
211
|
+
self, token_endpoint: str, *, client_id: str, code: str, redirect_uri: str, verifier: str
|
|
212
|
+
) -> TokenSet:
|
|
213
|
+
return self._token_request(
|
|
214
|
+
token_endpoint,
|
|
215
|
+
{
|
|
216
|
+
"grant_type": "authorization_code",
|
|
217
|
+
"code": code,
|
|
218
|
+
"redirect_uri": redirect_uri,
|
|
219
|
+
"client_id": client_id,
|
|
220
|
+
"code_verifier": verifier,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def refresh(self, token_endpoint: str, *, client_id: str, refresh_token: str) -> TokenSet:
|
|
225
|
+
return self._token_request(
|
|
226
|
+
token_endpoint,
|
|
227
|
+
{"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def start_device(self, endpoint: str, *, client_id: str) -> tuple[str, DeviceLogin]:
|
|
231
|
+
"""RFC 8628 §3.1:返回保密的轮询设备码,以及向用户展示的登录信息。"""
|
|
232
|
+
resp = self._post(endpoint, data={"client_id": client_id, "scope": SCOPE})
|
|
233
|
+
raw = self._json(resp)
|
|
234
|
+
if resp.status_code != 200:
|
|
235
|
+
raise self._error(resp, raw)
|
|
236
|
+
device_code = str(raw.get("device_code") or "")
|
|
237
|
+
user_code = str(raw.get("user_code") or "")
|
|
238
|
+
verification_uri = str(raw.get("verification_uri") or "")
|
|
239
|
+
if not device_code or not user_code or not verification_uri:
|
|
240
|
+
raise CliError("authorization server returned an incomplete device login")
|
|
241
|
+
# 展示给用户的登录页面必须与 Cheso 站点同源,不能指向其他站点。
|
|
242
|
+
base = self.settings.base_url
|
|
243
|
+
if _origin(verification_uri) != _origin(base):
|
|
244
|
+
raise CliError(f"authorization server sign-in page is not on {base}; refusing to log in")
|
|
245
|
+
try:
|
|
246
|
+
expires_in = float(raw.get("expires_in") or 0)
|
|
247
|
+
interval = float(raw.get("interval") or DEFAULT_DEVICE_INTERVAL)
|
|
248
|
+
except (TypeError, ValueError):
|
|
249
|
+
expires_in, interval = 0.0, DEFAULT_DEVICE_INTERVAL
|
|
250
|
+
if expires_in <= 0:
|
|
251
|
+
raise CliError("authorization server returned a device login without an expiry")
|
|
252
|
+
return device_code, DeviceLogin(
|
|
253
|
+
verification_uri=verification_uri,
|
|
254
|
+
user_code=user_code,
|
|
255
|
+
expires_at=self._now() + expires_in,
|
|
256
|
+
interval=max(interval, 1.0),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def poll_device(self, token_endpoint: str, *, client_id: str, device_code: str) -> TokenSet:
|
|
260
|
+
"""RFC 8628 §3.4:authorization_pending、slow_down 及最终错误通过 OAuthProtocolError 抛出。"""
|
|
261
|
+
return self._token_request(
|
|
262
|
+
token_endpoint, {"grant_type": DEVICE_GRANT_TYPE, "device_code": device_code, "client_id": client_id}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def revoke(self, revocation_endpoint: str, *, client_id: str, token: str, hint: str) -> None:
|
|
266
|
+
"""RFC 7009:5xx 表示撤销尚未完成,可以再次发送相同令牌;此处重试一次。"""
|
|
267
|
+
form = {"token": token, "token_type_hint": hint, "client_id": client_id}
|
|
268
|
+
resp = self._post(revocation_endpoint, data=form)
|
|
269
|
+
if resp.status_code >= 500:
|
|
270
|
+
resp = self._post(revocation_endpoint, data=form)
|
|
271
|
+
if resp.status_code != 200:
|
|
272
|
+
raise self._error(resp, self._json(resp))
|
|
273
|
+
|
|
274
|
+
def _token_request(self, endpoint: str, form: dict[str, str]) -> TokenSet:
|
|
275
|
+
resp = self._post(endpoint, data=form)
|
|
276
|
+
raw = self._json(resp)
|
|
277
|
+
if resp.status_code != 200:
|
|
278
|
+
raise self._error(resp, raw)
|
|
279
|
+
access = str(raw.get("access_token") or "")
|
|
280
|
+
refresh = str(raw.get("refresh_token") or "")
|
|
281
|
+
try:
|
|
282
|
+
expires_in = float(raw.get("expires_in") or 0)
|
|
283
|
+
except (TypeError, ValueError):
|
|
284
|
+
expires_in = 0.0
|
|
285
|
+
if not access:
|
|
286
|
+
raise CliError("authorization server returned no access token")
|
|
287
|
+
return TokenSet(access_token=access, refresh_token=refresh, expires_at=self._now() + max(expires_in, 0.0))
|
|
288
|
+
|
|
289
|
+
# ---- HTTP 请求与响应处理 ----
|
|
290
|
+
|
|
291
|
+
def _post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
292
|
+
return self._send("POST", url, **kwargs)
|
|
293
|
+
|
|
294
|
+
def _send(
|
|
295
|
+
self, method: str, url: str, *, target: str = "the authorization server", **kwargs: Any
|
|
296
|
+
) -> httpx.Response:
|
|
297
|
+
deadline = self._deadline
|
|
298
|
+
try:
|
|
299
|
+
if deadline is None:
|
|
300
|
+
return self._http.request(method, url, **kwargs)
|
|
301
|
+
# 同时限制各阶段超时,避免已放弃的请求在后台滞留过久。
|
|
302
|
+
kwargs["timeout"] = deadline.cap(httpx.Timeout(HTTP_TIMEOUT))
|
|
303
|
+
return call_until(deadline, lambda: self._http.request(method, url, **kwargs))
|
|
304
|
+
except TimeLimitReached:
|
|
305
|
+
raise
|
|
306
|
+
except httpx.TimeoutException as e:
|
|
307
|
+
if deadline is not None and deadline.expired():
|
|
308
|
+
raise TimeLimitReached() from e # 请求因 --timeout 被截断,而非请求自身过慢。
|
|
309
|
+
raise OAuthTimeout(f"{target} did not answer in time ({e.__class__.__name__})") from e
|
|
310
|
+
except httpx.HTTPError as e:
|
|
311
|
+
raise CliError(f"cannot reach {target}: {e.__class__.__name__}") from e
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _json(resp: httpx.Response) -> dict[str, Any]:
|
|
315
|
+
try:
|
|
316
|
+
raw = resp.json()
|
|
317
|
+
except ValueError:
|
|
318
|
+
return {}
|
|
319
|
+
return raw if isinstance(raw, dict) else {}
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _error(resp: httpx.Response, raw: dict[str, Any]) -> CliError:
|
|
323
|
+
error = str(raw.get("error") or "")
|
|
324
|
+
if error:
|
|
325
|
+
return OAuthProtocolError(error, str(raw.get("error_description") or ""), http_status=resp.status_code)
|
|
326
|
+
return CliError(f"authorization server request failed (http {resp.status_code})")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---- 回环登录 ----
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass
|
|
333
|
+
class _CallbackOutcome:
|
|
334
|
+
ok: bool
|
|
335
|
+
message: str
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class _CallbackServer(http.server.HTTPServer):
|
|
339
|
+
"""一次性回环服务器:第一个携带 OAuth 参数的 /callback 请求会结束等待。"""
|
|
340
|
+
|
|
341
|
+
def __init__(self, port: int, handle: Callable[[dict[str, str]], _CallbackOutcome]) -> None:
|
|
342
|
+
super().__init__((LOOPBACK_HOST, port), _CallbackHandler)
|
|
343
|
+
self.handle_callback = handle
|
|
344
|
+
self.outcome: _CallbackOutcome | None = None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
348
|
+
server: _CallbackServer
|
|
349
|
+
|
|
350
|
+
def do_GET(self) -> None: # noqa: N802 # pylint: disable=invalid-name # http.server 要求使用这个名称
|
|
351
|
+
parts = urlsplit(self.path)
|
|
352
|
+
query = {k: v[0] for k, v in parse_qs(parts.query, keep_blank_values=True).items()}
|
|
353
|
+
if parts.path != CALLBACK_PATH or not ({"code", "error", "state"} & query.keys()):
|
|
354
|
+
self._page(404, "Not found", "This address only receives the Cheso sign-in callback.")
|
|
355
|
+
return
|
|
356
|
+
if self.server.outcome is not None:
|
|
357
|
+
self._page(409, "Already handled", "This sign-in has already been processed. You can close this tab.")
|
|
358
|
+
return
|
|
359
|
+
try:
|
|
360
|
+
outcome = self.server.handle_callback(query)
|
|
361
|
+
except CliError as e:
|
|
362
|
+
outcome = _CallbackOutcome(False, e.message)
|
|
363
|
+
except Exception as e: # pylint: disable=broad-except # 错误交给 CLI 报告,不由 http.server 打印
|
|
364
|
+
outcome = _CallbackOutcome(False, f"unexpected error: {e.__class__.__name__}")
|
|
365
|
+
self.server.outcome = outcome
|
|
366
|
+
if outcome.ok:
|
|
367
|
+
self._page(200, "Signed in to Cheso", "The Cheso CLI is signed in. You can close this tab.")
|
|
368
|
+
else:
|
|
369
|
+
self._page(400, "Sign-in failed", outcome.message + " Return to the terminal for details.")
|
|
370
|
+
|
|
371
|
+
def _page(self, status: int, title: str, message: str) -> None:
|
|
372
|
+
body = (
|
|
373
|
+
"<!doctype html><html><head><meta charset='utf-8'><title>{t}</title></head>"
|
|
374
|
+
"<body style='font-family:sans-serif;margin:3em'><h2>{t}</h2><p>{m}</p></body></html>"
|
|
375
|
+
).format(t=html.escape(title), m=html.escape(message))
|
|
376
|
+
payload = body.encode("utf-8")
|
|
377
|
+
self.send_response(status)
|
|
378
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
379
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
380
|
+
self.send_header("Cache-Control", "no-store")
|
|
381
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
382
|
+
self.end_headers()
|
|
383
|
+
self.wfile.write(payload)
|
|
384
|
+
|
|
385
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - 签名由 http.server 规定
|
|
386
|
+
# 请求行中含有授权码,绝不能写入日志。
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def login(
|
|
391
|
+
settings: Settings,
|
|
392
|
+
store: CredentialStore,
|
|
393
|
+
*,
|
|
394
|
+
open_browser: bool = True,
|
|
395
|
+
timeout: float = DEFAULT_LOGIN_TIMEOUT,
|
|
396
|
+
port: int = 0,
|
|
397
|
+
transport: httpx.BaseTransport | None = None,
|
|
398
|
+
browser_open: Callable[[str], Any] = webbrowser.open,
|
|
399
|
+
out: TextIO,
|
|
400
|
+
now: Callable[[], float] = time.time,
|
|
401
|
+
) -> SiteCredentials:
|
|
402
|
+
"""执行浏览器登录并保存令牌,返回保存后的凭据。"""
|
|
403
|
+
base = settings.base_url
|
|
404
|
+
with OAuthClient(settings, transport=transport, now=now) as oauth:
|
|
405
|
+
meta = oauth.metadata()
|
|
406
|
+
client_id = _registered_client(oauth, store, base, meta)
|
|
407
|
+
verifier, challenge = pkce_pair()
|
|
408
|
+
state = secrets.token_urlsafe(32)
|
|
409
|
+
exchanged: list[TokenSet] = []
|
|
410
|
+
|
|
411
|
+
def handle(query: dict[str, str]) -> _CallbackOutcome:
|
|
412
|
+
# RFC 6749 §10.12 / RFC 9207:先校验 state,再校验 issuer,最后处理结果。
|
|
413
|
+
if not secrets.compare_digest(query.get("state", ""), state):
|
|
414
|
+
return _CallbackOutcome(
|
|
415
|
+
False, "The sign-in response did not match this login attempt (state mismatch)."
|
|
416
|
+
)
|
|
417
|
+
if query.get("iss", "").rstrip("/") != meta.issuer:
|
|
418
|
+
return _CallbackOutcome(False, "The sign-in response came from an unexpected issuer (iss mismatch).")
|
|
419
|
+
if query.get("error"):
|
|
420
|
+
reason = query.get("error_description") or query["error"]
|
|
421
|
+
if query["error"] == "access_denied":
|
|
422
|
+
reason = "Access was denied in the browser."
|
|
423
|
+
return _CallbackOutcome(False, f"Authorization failed: {reason}")
|
|
424
|
+
code = query.get("code", "")
|
|
425
|
+
if not code:
|
|
426
|
+
return _CallbackOutcome(False, "The sign-in response carried no authorization code.")
|
|
427
|
+
try:
|
|
428
|
+
tokens = oauth.exchange_code(
|
|
429
|
+
meta.token_endpoint, client_id=client_id, code=code, redirect_uri=redirect_uri, verifier=verifier
|
|
430
|
+
)
|
|
431
|
+
except OAuthProtocolError as e:
|
|
432
|
+
if e.error == ERROR_INVALID_CLIENT:
|
|
433
|
+
_forget_client(store, base)
|
|
434
|
+
return _CallbackOutcome(False, "The CLI registration is no longer valid. Run `cheso login` again.")
|
|
435
|
+
raise
|
|
436
|
+
exchanged.append(tokens)
|
|
437
|
+
return _CallbackOutcome(True, "")
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
server = _CallbackServer(port, handle)
|
|
441
|
+
except OSError as e:
|
|
442
|
+
raise CliError(f"cannot listen on {LOOPBACK_HOST}:{port}: {e.strerror or e}") from e
|
|
443
|
+
with server:
|
|
444
|
+
server.timeout = 0.5
|
|
445
|
+
redirect_uri = f"http://{LOOPBACK_HOST}:{server.server_address[1]}{CALLBACK_PATH}"
|
|
446
|
+
auth_url = (
|
|
447
|
+
meta.authorization_endpoint
|
|
448
|
+
+ "?"
|
|
449
|
+
+ urlencode(
|
|
450
|
+
{
|
|
451
|
+
"response_type": "code",
|
|
452
|
+
"client_id": client_id,
|
|
453
|
+
"redirect_uri": redirect_uri,
|
|
454
|
+
"state": state,
|
|
455
|
+
"code_challenge": challenge,
|
|
456
|
+
"code_challenge_method": "S256",
|
|
457
|
+
"scope": SCOPE,
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
print(f"Open this URL in a browser on this machine to sign in to {base}:\n\n {auth_url}\n", file=out)
|
|
462
|
+
print(
|
|
463
|
+
f"No browser on this machine (SSH, container, cloud agent)? "
|
|
464
|
+
f"Run `{settings.command('login --device')}` instead.",
|
|
465
|
+
file=out,
|
|
466
|
+
)
|
|
467
|
+
print(f"Waiting for the sign-in callback on {redirect_uri} ...", file=out)
|
|
468
|
+
if open_browser:
|
|
469
|
+
try:
|
|
470
|
+
browser_open(auth_url)
|
|
471
|
+
except Exception as e: # pylint: disable=broad-except # 打开浏览器失败不致命,上方已打印 URL
|
|
472
|
+
print(f"Could not open a browser ({e.__class__.__name__}); open the URL above.", file=out)
|
|
473
|
+
deadline = time.monotonic() + timeout
|
|
474
|
+
while server.outcome is None and time.monotonic() < deadline:
|
|
475
|
+
server.handle_request()
|
|
476
|
+
outcome = server.outcome
|
|
477
|
+
if outcome is None:
|
|
478
|
+
raise CliError(f"timed out after {int(timeout)} s waiting for the browser sign-in")
|
|
479
|
+
if not outcome.ok or not exchanged:
|
|
480
|
+
raise CliError(outcome.message or "sign-in failed")
|
|
481
|
+
|
|
482
|
+
return _save_login(oauth, store, base, meta, client_id, exchanged[0])
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _save_login(
|
|
486
|
+
oauth: OAuthClient, store: CredentialStore, base: str, meta: ServerMetadata, client_id: str, tokens: TokenSet
|
|
487
|
+
) -> SiteCredentials:
|
|
488
|
+
"""保存新的浏览器或设备码登录,并撤销被替换的旧授权。"""
|
|
489
|
+
with store.locked():
|
|
490
|
+
creds = store.get(base)
|
|
491
|
+
previous = (creds.client_id, creds.refresh_token, creds.revocation_endpoint)
|
|
492
|
+
creds.client_id = client_id
|
|
493
|
+
creds.issuer = meta.issuer
|
|
494
|
+
creds.token_endpoint = meta.token_endpoint
|
|
495
|
+
creds.revocation_endpoint = meta.revocation_endpoint
|
|
496
|
+
creds.access_token = tokens.access_token
|
|
497
|
+
creds.refresh_token = tokens.refresh_token
|
|
498
|
+
creds.expires_at = tokens.expires_at
|
|
499
|
+
creds.pending_device = {}
|
|
500
|
+
store.put(base, creds, make_default=True)
|
|
501
|
+
# 再次登录会替换旧授权;撤销旧授权,避免它一直留在“已授权的应用”中。
|
|
502
|
+
old_client, old_refresh, old_endpoint = previous
|
|
503
|
+
if old_refresh and old_endpoint:
|
|
504
|
+
try:
|
|
505
|
+
oauth.revoke(old_endpoint, client_id=old_client, token=old_refresh, hint="refresh_token")
|
|
506
|
+
except (CliError, TimeLimitReached):
|
|
507
|
+
pass
|
|
508
|
+
return creds
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def after_login(deadline: Deadline) -> Deadline:
|
|
512
|
+
"""登录成功后的收尾时限,用于撤销被替换的登录和查询账号信息。"""
|
|
513
|
+
return Deadline(deadline.at + AFTER_LOGIN_GRACE, deadline.clock)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ---- 设备码登录(RFC 8628)----
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def start_device_login(
|
|
520
|
+
settings: Settings,
|
|
521
|
+
store: CredentialStore,
|
|
522
|
+
*,
|
|
523
|
+
transport: httpx.BaseTransport | None = None,
|
|
524
|
+
now: Callable[[], float] = time.time,
|
|
525
|
+
deadline: Deadline | None = None,
|
|
526
|
+
) -> DeviceLogin:
|
|
527
|
+
"""申请设备码并保存到凭据文件,使 ``cheso login --wait`` 可以继续等待。
|
|
528
|
+
传入 ``deadline`` 时,任何请求都不能超过该截止时间,否则抛出 TimeLimitReached。
|
|
529
|
+
"""
|
|
530
|
+
base = settings.base_url
|
|
531
|
+
with (
|
|
532
|
+
OAuthClient(settings, transport=transport, now=now) as oauth,
|
|
533
|
+
oauth.time_limit(deadline) if deadline else contextlib.nullcontext(),
|
|
534
|
+
):
|
|
535
|
+
meta = oauth.metadata()
|
|
536
|
+
if not meta.device_authorization_endpoint:
|
|
537
|
+
raise CliError(
|
|
538
|
+
f"{base} does not offer sign-in with a code yet; run `{settings.command('login')}` "
|
|
539
|
+
"on a machine with a browser, or set CHESO_API_TOKEN"
|
|
540
|
+
)
|
|
541
|
+
client_id = _registered_client(oauth, store, base, meta)
|
|
542
|
+
try:
|
|
543
|
+
device_code, login = oauth.start_device(meta.device_authorization_endpoint, client_id=client_id)
|
|
544
|
+
except OAuthProtocolError as e:
|
|
545
|
+
if e.error == ERROR_INVALID_CLIENT:
|
|
546
|
+
_forget_client(store, base)
|
|
547
|
+
raise CliError(
|
|
548
|
+
f"The CLI registration is no longer valid. Run `{settings.command('login --device')}` again."
|
|
549
|
+
) from e
|
|
550
|
+
raise
|
|
551
|
+
with store.locked():
|
|
552
|
+
creds = store.get(base)
|
|
553
|
+
creds.pending_device = {
|
|
554
|
+
"device_code": device_code,
|
|
555
|
+
"client_id": client_id,
|
|
556
|
+
"issuer": meta.issuer,
|
|
557
|
+
"verification_uri": login.verification_uri,
|
|
558
|
+
"user_code": login.user_code,
|
|
559
|
+
"expires_at": login.expires_at,
|
|
560
|
+
"interval": login.interval,
|
|
561
|
+
}
|
|
562
|
+
store.put(base, creds)
|
|
563
|
+
return login
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def pending_device_login(settings: Settings, store: CredentialStore) -> DeviceLogin | None:
|
|
567
|
+
"""返回当前站点等待用户确认的设备码登录;没有时返回 None。"""
|
|
568
|
+
pending = store.get(settings.base_url).pending_device
|
|
569
|
+
if not pending.get("device_code"):
|
|
570
|
+
return None
|
|
571
|
+
try:
|
|
572
|
+
return DeviceLogin(
|
|
573
|
+
verification_uri=str(pending.get("verification_uri") or ""),
|
|
574
|
+
user_code=str(pending.get("user_code") or ""),
|
|
575
|
+
expires_at=float(pending.get("expires_at") or 0),
|
|
576
|
+
interval=max(float(pending.get("interval") or DEFAULT_DEVICE_INTERVAL), 1.0),
|
|
577
|
+
)
|
|
578
|
+
except (TypeError, ValueError):
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _clear_pending(store: CredentialStore, base: str) -> None:
|
|
583
|
+
with store.locked():
|
|
584
|
+
creds = store.get(base)
|
|
585
|
+
creds.pending_device = {}
|
|
586
|
+
store.put(base, creds)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _remember_poll(
|
|
590
|
+
store: CredentialStore, base: str, device_code: str, *, interval: float, backoff: float, next_poll_at: float
|
|
591
|
+
) -> None:
|
|
592
|
+
"""保留轮询节奏,再次运行 ``cheso login --wait`` 时按原节奏继续,避免立即轮询,见 RFC 8628 §3.5。
|
|
593
|
+
|
|
594
|
+
``interval`` 是基础间隔,slow_down 增大后持续生效;``backoff`` 是连续连接超时累计的退避间隔,
|
|
595
|
+
令牌端点正常答复后恢复为 ``interval``;``next_poll_at`` 是最早下次轮询时间。
|
|
596
|
+
"""
|
|
597
|
+
with store.locked():
|
|
598
|
+
creds = store.get(base)
|
|
599
|
+
if creds.pending_device.get("device_code") != device_code:
|
|
600
|
+
return # 等待期间,登录已完成或被另一个命令替换。
|
|
601
|
+
creds.pending_device["interval"] = interval
|
|
602
|
+
creds.pending_device["backoff"] = backoff
|
|
603
|
+
creds.pending_device["next_poll_at"] = next_poll_at
|
|
604
|
+
store.put(base, creds)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _resume_wait(pending: dict[str, Any], interval: float, now: float) -> float:
|
|
608
|
+
"""运行 ``cheso login --wait`` 后,首次轮询前仍需等待的秒数。"""
|
|
609
|
+
try:
|
|
610
|
+
next_poll_at = float(pending.get("next_poll_at") or 0)
|
|
611
|
+
except (TypeError, ValueError):
|
|
612
|
+
next_poll_at = 0.0
|
|
613
|
+
return min(max(next_poll_at - now, 0.0), max(interval, MAX_BACKOFF_INTERVAL))
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _resume_backoff(pending: dict[str, Any], interval: float) -> float:
|
|
617
|
+
"""恢复上一条命令累计的连接超时退避间隔,限制在 [interval, max(60 秒, interval)] 内。
|
|
618
|
+
旧版本保存的记录没有该字段,或字段无效时,从 ``interval`` 开始。
|
|
619
|
+
"""
|
|
620
|
+
try:
|
|
621
|
+
backoff = float(pending.get("backoff") or interval)
|
|
622
|
+
except (TypeError, ValueError):
|
|
623
|
+
return interval
|
|
624
|
+
if not math.isfinite(backoff):
|
|
625
|
+
return interval
|
|
626
|
+
return min(max(backoff, interval), max(MAX_BACKOFF_INTERVAL, interval))
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _give_up(settings: Settings, store: CredentialStore, error: OAuthProtocolError) -> NoReturn:
|
|
630
|
+
"""处理轮询的最终结果:清除待授权登录,并说明后续操作。"""
|
|
631
|
+
base = settings.base_url
|
|
632
|
+
_clear_pending(store, base)
|
|
633
|
+
if error.error == ERROR_ACCESS_DENIED:
|
|
634
|
+
raise CliError("The sign-in was denied in the browser.", kind=KIND_LOGIN_DENIED) from error
|
|
635
|
+
if error.error in (ERROR_EXPIRED_TOKEN, ERROR_INVALID_GRANT):
|
|
636
|
+
raise _expired(settings) from error
|
|
637
|
+
if error.error == ERROR_INVALID_CLIENT:
|
|
638
|
+
_forget_client(store, base)
|
|
639
|
+
raise error
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def wait_device_login(
|
|
643
|
+
settings: Settings,
|
|
644
|
+
store: CredentialStore,
|
|
645
|
+
*,
|
|
646
|
+
deadline: Deadline,
|
|
647
|
+
poll_first: bool,
|
|
648
|
+
transport: httpx.BaseTransport | None = None,
|
|
649
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
650
|
+
now: Callable[[], float] = time.time,
|
|
651
|
+
out: TextIO,
|
|
652
|
+
) -> SiteCredentials | None:
|
|
653
|
+
"""轮询令牌端点,直到用户允许。成功时返回已保存的登录;用户尚未回答而 ``deadline`` 已到时返回 None。
|
|
654
|
+
超时后保留待授权登录,可再次运行 ``cheso login --wait``。被拒绝或已过期时清除记录并抛出 CliError。
|
|
655
|
+
|
|
656
|
+
``deadline`` 约束此处的每个请求,包括读取完整响应。成功登录的凭据保存不会被截断;
|
|
657
|
+
撤销被替换的旧登录可额外使用 AFTER_LOGIN_GRACE。
|
|
658
|
+
若允许授权的响应尚未完整到达就超时,此次授权已被消费,下次 ``cheso login --wait`` 会报告 login_expired。
|
|
659
|
+
"""
|
|
660
|
+
base = settings.base_url
|
|
661
|
+
pending = dict(store.get(base).pending_device)
|
|
662
|
+
login = pending_device_login(settings, store)
|
|
663
|
+
if login is None:
|
|
664
|
+
raise UsageError(f"no sign-in is waiting; start one with `{settings.command('login --device')}`")
|
|
665
|
+
device_code, client_id = str(pending["device_code"]), str(pending.get("client_id") or "")
|
|
666
|
+
# 短码过期和 --timeout 使用同一时钟,以统一方式检查截止时间。
|
|
667
|
+
expires = Deadline(deadline.clock() + (login.expires_at - now()), deadline.clock)
|
|
668
|
+
interval = login.interval
|
|
669
|
+
# 续等时沿用上一条命令的退避级数:把一次等待拆成多条命令,不应清除连续连接超时的退避。
|
|
670
|
+
backoff = _resume_backoff(pending, interval)
|
|
671
|
+
wait_for = _resume_wait(pending, interval, now()) if poll_first else interval
|
|
672
|
+
with OAuthClient(settings, transport=transport, now=now) as oauth, oauth.time_limit(deadline):
|
|
673
|
+
try:
|
|
674
|
+
meta = oauth.metadata()
|
|
675
|
+
except TimeLimitReached:
|
|
676
|
+
return None
|
|
677
|
+
if meta.issuer != pending.get("issuer"):
|
|
678
|
+
_clear_pending(store, base)
|
|
679
|
+
raise CliError(
|
|
680
|
+
f"the waiting sign-in was started with another server; run `{settings.command('login --device')}`",
|
|
681
|
+
kind=KIND_LOGIN_EXPIRED,
|
|
682
|
+
)
|
|
683
|
+
while True:
|
|
684
|
+
if expires.expired():
|
|
685
|
+
_clear_pending(store, base)
|
|
686
|
+
raise _expired(settings)
|
|
687
|
+
if wait_for > 0:
|
|
688
|
+
if deadline.clock() + wait_for >= deadline.at:
|
|
689
|
+
return None
|
|
690
|
+
# 等待时间不能超过短码有效期;退避间隔可能大于剩余有效时间。
|
|
691
|
+
sleep(min(wait_for, max(expires.at - deadline.clock(), 0.0)))
|
|
692
|
+
if expires.expired():
|
|
693
|
+
continue
|
|
694
|
+
if deadline.expired():
|
|
695
|
+
return None
|
|
696
|
+
wait_for = interval
|
|
697
|
+
try:
|
|
698
|
+
tokens = oauth.poll_device(meta.token_endpoint, client_id=client_id, device_code=device_code)
|
|
699
|
+
except TimeLimitReached:
|
|
700
|
+
# 轮询可能已到达服务器,但没有得到答复:下次 --wait 先按当前节奏等待,
|
|
701
|
+
# 即基础间隔,或连续连接超时后的退避间隔;退避级数保持不变。
|
|
702
|
+
_remember_poll(
|
|
703
|
+
store, base, device_code, interval=interval, backoff=backoff, next_poll_at=now() + backoff
|
|
704
|
+
)
|
|
705
|
+
return None
|
|
706
|
+
except OAuthProtocolError as e:
|
|
707
|
+
if e.error == ERROR_SLOW_DOWN:
|
|
708
|
+
interval += SLOW_DOWN_STEP
|
|
709
|
+
wait_for = backoff = interval
|
|
710
|
+
elif e.http_status is not None and e.http_status >= 500:
|
|
711
|
+
# 5xx 不算正常答复:按基础间隔重试,退避级数不变(没有错误体的 5xx 走下面的 CliError,处理相同)。
|
|
712
|
+
print(f"temporary server error ({e.error}); retrying", file=out)
|
|
713
|
+
elif e.error == ERROR_AUTHORIZATION_PENDING:
|
|
714
|
+
backoff = interval # 令牌端点正常答复,退避恢复为基础间隔。
|
|
715
|
+
else:
|
|
716
|
+
_give_up(settings, store, e)
|
|
717
|
+
except OAuthTimeout as e:
|
|
718
|
+
# RFC 8628 §3.5:连接超时后降低轮询频率,使用指数退避。
|
|
719
|
+
backoff = min(backoff * 2, max(MAX_BACKOFF_INTERVAL, interval))
|
|
720
|
+
wait_for = backoff
|
|
721
|
+
print(f"temporary error ({e.message}); retrying in {wait_for:.0f} s", file=out)
|
|
722
|
+
except CliError as e:
|
|
723
|
+
# 其他网络错误不结束服务器上的待授权登录,在截止时间前继续重试。
|
|
724
|
+
print(f"temporary error ({e.message}); retrying", file=out)
|
|
725
|
+
else:
|
|
726
|
+
with oauth.time_limit(after_login(deadline)):
|
|
727
|
+
return _save_login(oauth, store, base, meta, client_id, tokens)
|
|
728
|
+
_remember_poll(store, base, device_code, interval=interval, backoff=backoff, next_poll_at=now() + wait_for)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _expired(settings: Settings) -> CliError:
|
|
732
|
+
return CliError(
|
|
733
|
+
f"The sign-in code expired before it was approved. Run `{settings.command('login --device')}` again.",
|
|
734
|
+
kind=KIND_LOGIN_EXPIRED,
|
|
735
|
+
extra={"next": settings.command("login --device")},
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _registered_client(oauth: OAuthClient, store: CredentialStore, base: str, meta: ServerMetadata) -> str:
|
|
740
|
+
"""复用当前站点保存的客户端注册;没有注册信息时先注册。"""
|
|
741
|
+
creds = store.get(base)
|
|
742
|
+
if creds.client_id and creds.issuer == meta.issuer:
|
|
743
|
+
return creds.client_id
|
|
744
|
+
client_id = oauth.register(meta)
|
|
745
|
+
with store.locked():
|
|
746
|
+
creds = store.get(base)
|
|
747
|
+
creds.client_id = client_id
|
|
748
|
+
creds.issuer = meta.issuer
|
|
749
|
+
creds.token_endpoint = meta.token_endpoint
|
|
750
|
+
creds.revocation_endpoint = meta.revocation_endpoint
|
|
751
|
+
store.put(base, creds)
|
|
752
|
+
return client_id
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _forget_client(store: CredentialStore, base: str) -> None:
|
|
756
|
+
with store.locked():
|
|
757
|
+
creds = store.get(base)
|
|
758
|
+
creds.client_id = ""
|
|
759
|
+
creds.clear_tokens()
|
|
760
|
+
store.put(base, creds)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def logout(
|
|
764
|
+
settings: Settings,
|
|
765
|
+
store: CredentialStore,
|
|
766
|
+
*,
|
|
767
|
+
transport: httpx.BaseTransport | None = None,
|
|
768
|
+
out: TextIO,
|
|
769
|
+
) -> bool:
|
|
770
|
+
"""尽力撤销服务器上的授权,并删除本地令牌;返回此前是否存在令牌。"""
|
|
771
|
+
base = settings.base_url
|
|
772
|
+
with store.locked():
|
|
773
|
+
creds = store.get(base)
|
|
774
|
+
if not creds.logged_in:
|
|
775
|
+
return False
|
|
776
|
+
token, hint = (
|
|
777
|
+
(creds.refresh_token, "refresh_token") if creds.refresh_token else (creds.access_token, "access_token")
|
|
778
|
+
)
|
|
779
|
+
endpoint = creds.revocation_endpoint
|
|
780
|
+
try:
|
|
781
|
+
with OAuthClient(settings, transport=transport) as oauth:
|
|
782
|
+
if not endpoint:
|
|
783
|
+
endpoint = oauth.metadata().revocation_endpoint
|
|
784
|
+
if endpoint:
|
|
785
|
+
oauth.revoke(endpoint, client_id=creds.client_id, token=token, hint=hint)
|
|
786
|
+
else:
|
|
787
|
+
print("warning: the server offers no revocation endpoint; only local tokens were removed", file=out)
|
|
788
|
+
except CliError as e:
|
|
789
|
+
print(
|
|
790
|
+
f"warning: could not revoke the authorization on the server ({e.message}); "
|
|
791
|
+
"remove it under Authorized apps on the Cheso website",
|
|
792
|
+
file=out,
|
|
793
|
+
)
|
|
794
|
+
creds.clear_tokens()
|
|
795
|
+
creds.pending_device = {}
|
|
796
|
+
store.put(base, creds)
|
|
797
|
+
return True
|