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/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