uv-agent-auth-code 0.1.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.
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from uv_agent.plugins import PluginManifest, SetupPlugin
6
+
7
+ from .service import AuthCodeConfig, AuthCodeService
8
+
9
+ MANIFEST = PluginManifest(
10
+ id="auth-code",
11
+ version="0.1.0",
12
+ display_name={"en": "Auth Code", "zh": "验证码鉴权"},
13
+ description={
14
+ "en": "Starts a token-protected web page with a short-lived challenge code and exposes auth_code.verify.",
15
+ "zh": "启动受 token 保护的验证码页面,并提供 auth_code.verify action。",
16
+ },
17
+ capabilities=("action", "http_server"),
18
+ config_schema={
19
+ "type": "object",
20
+ "properties": {
21
+ "token": {"type": "string", "minLength": 1},
22
+ "host": {"type": "string", "default": "0.0.0.0"},
23
+ "port": {"type": "integer", "minimum": 0, "maximum": 65535, "default": 8765},
24
+ "ttl_s": {"type": "integer", "minimum": 1, "default": 120},
25
+ "session_ttl_s": {"type": "integer", "minimum": 60, "default": 43200},
26
+ },
27
+ "required": ["token"],
28
+ },
29
+ )
30
+
31
+ _SERVICES: dict[int, AuthCodeService] = {}
32
+
33
+
34
+ def plugin() -> SetupPlugin:
35
+ return SetupPlugin(manifest=MANIFEST, setup=setup, stop=stop)
36
+
37
+
38
+ def setup(context) -> None:
39
+ config = AuthCodeConfig.from_mapping(context.config)
40
+ service = AuthCodeService(config, logger=context.logger)
41
+ service.start()
42
+ _SERVICES[id(context)] = service
43
+ try:
44
+ context.actions.register(
45
+ "auth_code.verify",
46
+ _verify_action,
47
+ doc="Verify the current auth-code challenge. Payload: {'code': 'A7K2Q9'}.",
48
+ schema={
49
+ "type": "object",
50
+ "properties": {"code": {"type": "string"}},
51
+ "required": ["code"],
52
+ },
53
+ )
54
+ except Exception:
55
+ _SERVICES.pop(id(context), None)
56
+ service.stop()
57
+ raise
58
+ context.logger.info("Auth code server started url=%s", service.url)
59
+
60
+
61
+ def stop(context) -> None:
62
+ service = _SERVICES.pop(id(context), None)
63
+ if service is not None:
64
+ service.stop()
65
+
66
+
67
+ def _verify_action(payload: dict[str, Any], context=None) -> dict[str, Any]:
68
+ if context is None:
69
+ return {"ok": False, "verified": False, "reason": "missing_context"}
70
+ service = _SERVICES.get(id(context))
71
+ if service is None:
72
+ return {"ok": False, "verified": False, "reason": "service_unavailable"}
73
+ return service.verify(str(payload.get("code") or ""))
@@ -0,0 +1,465 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import logging
6
+ import secrets
7
+ import string
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from http import HTTPStatus
13
+ from http.cookies import SimpleCookie
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from random import SystemRandom
16
+ from typing import Any
17
+ from urllib.parse import parse_qs, urlsplit
18
+
19
+ CODE_LENGTH = 6
20
+ SESSION_COOKIE = "uv_agent_auth_code_session"
21
+ LETTERS = string.ascii_uppercase
22
+ DIGITS = string.digits
23
+ ALPHABET = LETTERS + DIGITS
24
+ _RANDOM = SystemRandom()
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AuthCodeConfig:
29
+ token: str
30
+ host: str = "0.0.0.0"
31
+ port: int = 8765
32
+ ttl_s: int = 120
33
+ session_ttl_s: int = 43200
34
+
35
+ @classmethod
36
+ def from_mapping(cls, value: dict[str, Any] | None) -> "AuthCodeConfig":
37
+ data = dict(value or {})
38
+ token = str(data.get("token") or "").strip()
39
+ if not token:
40
+ raise ValueError("auth-code config requires a non-empty token")
41
+ host = str(data.get("host") or "0.0.0.0").strip() or "0.0.0.0"
42
+ port = _int_range(data.get("port", 8765), "port", minimum=0, maximum=65535)
43
+ ttl_s = _int_range(data.get("ttl_s", 120), "ttl_s", minimum=1, maximum=86400)
44
+ session_ttl_s = _int_range(data.get("session_ttl_s", 43200), "session_ttl_s", minimum=60, maximum=604800)
45
+ return cls(token=token, host=host, port=port, ttl_s=ttl_s, session_ttl_s=session_ttl_s)
46
+
47
+
48
+ class ChallengeStore:
49
+ def __init__(self, *, ttl_s: int) -> None:
50
+ self.ttl_s = ttl_s
51
+ self._lock = threading.RLock()
52
+ self._code = ""
53
+ self._expires_at = 0.0
54
+
55
+ def snapshot(self) -> dict[str, Any]:
56
+ with self._lock:
57
+ now = time.time()
58
+ if self._is_expired(now):
59
+ self._rotate_locked(now)
60
+ return self._snapshot_locked(now)
61
+
62
+ def verify(self, code: str) -> dict[str, Any]:
63
+ candidate = normalize_code(code)
64
+ with self._lock:
65
+ now = time.time()
66
+ if not candidate:
67
+ return self._failure_locked("empty_code", now)
68
+ if self._is_expired(now):
69
+ self._rotate_locked(now)
70
+ return self._failure_locked("expired", now)
71
+ if not secrets.compare_digest(candidate, self._code):
72
+ return self._failure_locked("invalid", now)
73
+ self._rotate_locked(now)
74
+ return {
75
+ "ok": True,
76
+ "verified": True,
77
+ "ttl_s": self.ttl_s,
78
+ "expires_at": _iso_utc(self._expires_at),
79
+ }
80
+
81
+ def _failure_locked(self, reason: str, now: float) -> dict[str, Any]:
82
+ if self._is_expired(now):
83
+ self._rotate_locked(now)
84
+ return {
85
+ "ok": False,
86
+ "verified": False,
87
+ "reason": reason,
88
+ "ttl_s": self.ttl_s,
89
+ "expires_at": _iso_utc(self._expires_at),
90
+ }
91
+
92
+ def _snapshot_locked(self, now: float) -> dict[str, Any]:
93
+ return {
94
+ "code": self._code,
95
+ "ttl_s": self.ttl_s,
96
+ "expires_at": _iso_utc(self._expires_at),
97
+ "remaining_s": max(0, int(round(self._expires_at - now))),
98
+ }
99
+
100
+ def _is_expired(self, now: float) -> bool:
101
+ return not self._code or now >= self._expires_at
102
+
103
+ def _rotate_locked(self, now: float) -> None:
104
+ self._code = generate_code()
105
+ self._expires_at = now + self.ttl_s
106
+
107
+
108
+ class AuthCodeService:
109
+ def __init__(self, config: AuthCodeConfig, *, logger: logging.Logger | None = None) -> None:
110
+ self.config = config
111
+ self.logger = logger or logging.getLogger(__name__)
112
+ self.challenge = ChallengeStore(ttl_s=config.ttl_s)
113
+ self._sessions = SessionStore(ttl_s=config.session_ttl_s)
114
+ self._lock = threading.RLock()
115
+ self._httpd: ThreadingHTTPServer | None = None
116
+ self._thread: threading.Thread | None = None
117
+ self._url = ""
118
+
119
+ @property
120
+ def url(self) -> str:
121
+ return self._url
122
+
123
+ @property
124
+ def port(self) -> int:
125
+ with self._lock:
126
+ if self._httpd is None:
127
+ return self.config.port
128
+ return int(self._httpd.server_address[1])
129
+
130
+ def start(self) -> None:
131
+ with self._lock:
132
+ if self._httpd is not None:
133
+ return
134
+ handler = self._handler_class()
135
+ httpd = ThreadingHTTPServer((self.config.host, self.config.port), handler)
136
+ httpd.daemon_threads = True
137
+ self._httpd = httpd
138
+ host, port = httpd.server_address[:2]
139
+ self._url = f"http://{_display_host(str(host))}:{port}"
140
+ self._thread = threading.Thread(target=httpd.serve_forever, name="uv-agent-auth-code-http", daemon=True)
141
+ self._thread.start()
142
+
143
+ def stop(self) -> None:
144
+ with self._lock:
145
+ httpd = self._httpd
146
+ thread = self._thread
147
+ self._httpd = None
148
+ self._thread = None
149
+ self._url = ""
150
+ if httpd is not None:
151
+ httpd.shutdown()
152
+ httpd.server_close()
153
+ if thread is not None and thread.is_alive():
154
+ thread.join(timeout=2.0)
155
+
156
+ def verify(self, code: str) -> dict[str, Any]:
157
+ return self.challenge.verify(code)
158
+
159
+ def _handler_class(self) -> type[BaseHTTPRequestHandler]:
160
+ service = self
161
+
162
+ class AuthCodeRequestHandler(BaseHTTPRequestHandler):
163
+ server_version = "UvAgentAuthCode/1"
164
+
165
+ def do_GET(self) -> None: # noqa: N802 - stdlib handler API
166
+ try:
167
+ self._handle_get()
168
+ except Exception as exc:
169
+ service.logger.warning("Auth code HTTP request failed error_type=%s", exc.__class__.__name__)
170
+ self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
171
+
172
+ def log_message(self, _format: str, *_args: Any) -> None:
173
+ return
174
+
175
+ def _handle_get(self) -> None:
176
+ parsed = urlsplit(self.path)
177
+ params = parse_qs(parsed.query, keep_blank_values=True)
178
+ if parsed.path == "/healthz":
179
+ self._send_bytes(HTTPStatus.OK, b"ok\n", content_type="text/plain; charset=utf-8")
180
+ return
181
+ token = _first(params.get("token"))
182
+ if token:
183
+ self._handle_token_login(token)
184
+ return
185
+ if not self._authenticated():
186
+ self._send_unauthorized()
187
+ return
188
+ if parsed.path in {"", "/", "/index.html"}:
189
+ self._send_html()
190
+ return
191
+ if parsed.path == "/api/challenge":
192
+ self._send_json(service.challenge.snapshot())
193
+ return
194
+ if parsed.path == "/logout":
195
+ self._logout()
196
+ return
197
+ self.send_error(HTTPStatus.NOT_FOUND)
198
+
199
+ def _handle_token_login(self, token: str) -> None:
200
+ if not service._valid_token(token):
201
+ self._send_unauthorized()
202
+ return
203
+ session_id = service._sessions.create()
204
+ self.send_response(HTTPStatus.SEE_OTHER)
205
+ self.send_header("Location", "/")
206
+ self.send_header(
207
+ "Set-Cookie",
208
+ f"{SESSION_COOKIE}={session_id}; HttpOnly; SameSite=Lax; Path=/; Max-Age={service.config.session_ttl_s}",
209
+ )
210
+ self.send_header("Content-Length", "0")
211
+ self.end_headers()
212
+
213
+ def _authenticated(self) -> bool:
214
+ auth = str(self.headers.get("Authorization") or "")
215
+ scheme, separator, token = auth.partition(" ")
216
+ if separator and scheme.lower() == "bearer" and service._valid_token(token):
217
+ return True
218
+ cookie = SimpleCookie()
219
+ cookie.load(str(self.headers.get("Cookie") or ""))
220
+ morsel = cookie.get(SESSION_COOKIE)
221
+ return morsel is not None and service._sessions.valid(morsel.value)
222
+
223
+ def _logout(self) -> None:
224
+ cookie = SimpleCookie()
225
+ cookie.load(str(self.headers.get("Cookie") or ""))
226
+ morsel = cookie.get(SESSION_COOKIE)
227
+ if morsel is not None:
228
+ service._sessions.delete(morsel.value)
229
+ self.send_response(HTTPStatus.SEE_OTHER)
230
+ self.send_header("Location", "/")
231
+ self.send_header("Set-Cookie", f"{SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0")
232
+ self.send_header("Content-Length", "0")
233
+ self.end_headers()
234
+
235
+ def _send_html(self) -> None:
236
+ snapshot = service.challenge.snapshot()
237
+ body = _render_page(snapshot).encode("utf-8")
238
+ self._send_bytes(HTTPStatus.OK, body, content_type="text/html; charset=utf-8")
239
+
240
+ def _send_unauthorized(self) -> None:
241
+ body = _render_unauthorized().encode("utf-8")
242
+ self._send_bytes(HTTPStatus.UNAUTHORIZED, body, content_type="text/html; charset=utf-8")
243
+
244
+ def _send_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
245
+ body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
246
+ self._send_bytes(status, body, content_type="application/json; charset=utf-8")
247
+
248
+ def _send_bytes(self, status: HTTPStatus, body: bytes, *, content_type: str) -> None:
249
+ self.send_response(status)
250
+ self.send_header("Content-Type", content_type)
251
+ self.send_header("Cache-Control", "no-store")
252
+ self.send_header("Content-Length", str(len(body)))
253
+ self.end_headers()
254
+ self.wfile.write(body)
255
+
256
+ return AuthCodeRequestHandler
257
+
258
+ def _valid_token(self, token: str) -> bool:
259
+ return secrets.compare_digest(str(token or ""), self.config.token)
260
+
261
+
262
+ class SessionStore:
263
+ def __init__(self, *, ttl_s: int) -> None:
264
+ self.ttl_s = ttl_s
265
+ self._lock = threading.RLock()
266
+ self._sessions: dict[str, float] = {}
267
+
268
+ def create(self) -> str:
269
+ now = time.time()
270
+ self._prune_locked(now)
271
+ session_id = secrets.token_urlsafe(32)
272
+ with self._lock:
273
+ self._sessions[session_id] = now + self.ttl_s
274
+ return session_id
275
+
276
+ def valid(self, session_id: str) -> bool:
277
+ now = time.time()
278
+ with self._lock:
279
+ expires_at = self._sessions.get(session_id)
280
+ if expires_at is None:
281
+ return False
282
+ if now >= expires_at:
283
+ self._sessions.pop(session_id, None)
284
+ return False
285
+ return True
286
+
287
+ def delete(self, session_id: str) -> None:
288
+ with self._lock:
289
+ self._sessions.pop(session_id, None)
290
+
291
+ def _prune_locked(self, now: float) -> None:
292
+ with self._lock:
293
+ expired = [session_id for session_id, expires_at in self._sessions.items() if now >= expires_at]
294
+ for session_id in expired:
295
+ self._sessions.pop(session_id, None)
296
+
297
+
298
+ def generate_code() -> str:
299
+ chars = [secrets.choice(LETTERS), secrets.choice(DIGITS)]
300
+ chars.extend(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH - len(chars)))
301
+ _RANDOM.shuffle(chars)
302
+ return "".join(chars)
303
+
304
+
305
+ def normalize_code(code: str) -> str:
306
+ return str(code or "").strip().upper()
307
+
308
+
309
+ def _render_page(snapshot: dict[str, Any]) -> str:
310
+ code = html.escape(str(snapshot["code"]))
311
+ expires_at = html.escape(str(snapshot["expires_at"]))
312
+ return f"""<!doctype html>
313
+ <html lang="en">
314
+ <head>
315
+ <meta charset="utf-8">
316
+ <meta name="viewport" content="width=device-width, initial-scale=1">
317
+ <title>Auth Code</title>
318
+ <style>
319
+ :root {{
320
+ color-scheme: light dark;
321
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
322
+ background: #f7f9fb;
323
+ color: #171717;
324
+ }}
325
+ body {{
326
+ min-height: 100vh;
327
+ margin: 0;
328
+ display: grid;
329
+ place-items: center;
330
+ }}
331
+ main {{
332
+ width: min(92vw, 420px);
333
+ padding: 32px;
334
+ border: 1px solid #d8dee8;
335
+ border-radius: 8px;
336
+ background: #ffffff;
337
+ box-shadow: 0 18px 44px rgba(27, 31, 35, 0.12);
338
+ }}
339
+ h1 {{
340
+ margin: 0 0 18px;
341
+ font-size: 16px;
342
+ font-weight: 650;
343
+ }}
344
+ #code {{
345
+ display: block;
346
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
347
+ font-size: clamp(48px, 15vw, 82px);
348
+ font-weight: 800;
349
+ line-height: 1;
350
+ letter-spacing: 0;
351
+ margin: 8px 0 20px;
352
+ overflow-wrap: anywhere;
353
+ color: #166534;
354
+ }}
355
+ .meta {{
356
+ display: flex;
357
+ justify-content: space-between;
358
+ gap: 16px;
359
+ color: #596579;
360
+ font-size: 14px;
361
+ }}
362
+ @media (prefers-color-scheme: dark) {{
363
+ :root {{
364
+ background: #111315;
365
+ color: #f4f1ea;
366
+ }}
367
+ main {{
368
+ background: #191c1f;
369
+ border-color: #343941;
370
+ box-shadow: none;
371
+ }}
372
+ #code {{
373
+ color: #7ddf9c;
374
+ }}
375
+ .meta {{
376
+ color: #bbb5aa;
377
+ }}
378
+ }}
379
+ </style>
380
+ </head>
381
+ <body>
382
+ <main>
383
+ <h1>Current challenge</h1>
384
+ <output id="code">{code}</output>
385
+ <div class="meta">
386
+ <span id="remaining"></span>
387
+ <time id="expires" datetime="{expires_at}">{expires_at}</time>
388
+ </div>
389
+ </main>
390
+ <script>
391
+ const codeEl = document.getElementById("code");
392
+ const remainingEl = document.getElementById("remaining");
393
+ const expiresEl = document.getElementById("expires");
394
+ let expiresAt = Date.parse(expiresEl.dateTime);
395
+
396
+ function tick() {{
397
+ const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
398
+ remainingEl.textContent = seconds + "s";
399
+ if (seconds <= 1) {{
400
+ refresh();
401
+ }}
402
+ }}
403
+
404
+ async function refresh() {{
405
+ const response = await fetch("/api/challenge", {{cache: "no-store"}});
406
+ if (!response.ok) {{
407
+ location.reload();
408
+ return;
409
+ }}
410
+ const data = await response.json();
411
+ codeEl.textContent = data.code;
412
+ expiresEl.textContent = data.expires_at;
413
+ expiresEl.dateTime = data.expires_at;
414
+ expiresAt = Date.parse(data.expires_at);
415
+ tick();
416
+ }}
417
+
418
+ setInterval(tick, 250);
419
+ setInterval(refresh, 5000);
420
+ tick();
421
+ </script>
422
+ </body>
423
+ </html>
424
+ """
425
+
426
+
427
+ def _render_unauthorized() -> str:
428
+ return """<!doctype html>
429
+ <html lang="en">
430
+ <head>
431
+ <meta charset="utf-8">
432
+ <meta name="viewport" content="width=device-width, initial-scale=1">
433
+ <title>Unauthorized</title>
434
+ </head>
435
+ <body>
436
+ <main>Unauthorized</main>
437
+ </body>
438
+ </html>
439
+ """
440
+
441
+
442
+ def _iso_utc(epoch_seconds: float) -> str:
443
+ return datetime.fromtimestamp(epoch_seconds, UTC).isoformat().replace("+00:00", "Z")
444
+
445
+
446
+ def _display_host(host: str) -> str:
447
+ if host in {"", "0.0.0.0", "::"}:
448
+ return "127.0.0.1"
449
+ return host
450
+
451
+
452
+ def _first(values: list[str] | None) -> str:
453
+ if not values:
454
+ return ""
455
+ return str(values[0] or "")
456
+
457
+
458
+ def _int_range(value: Any, label: str, *, minimum: int, maximum: int) -> int:
459
+ try:
460
+ result = int(value)
461
+ except (TypeError, ValueError) as exc:
462
+ raise ValueError(f"auth-code config {label} must be an integer") from exc
463
+ if result < minimum or result > maximum:
464
+ raise ValueError(f"auth-code config {label} must be between {minimum} and {maximum}")
465
+ return result
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: uv-agent-auth-code
3
+ Version: 0.1.0
4
+ Summary: Local auth-code challenge plugin for uv-agent.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: uv-agent>=0.21.0
7
+ Description-Content-Type: text/markdown
8
+
9
+ # uv-agent auth code plugin
10
+
11
+ This plugin starts a small token-protected HTTP page that shows a single
12
+ six-character challenge code. Other uv-agent plugins can call the
13
+ `auth_code.verify` action with a user-provided code.
14
+
15
+ The code is uppercase alphanumeric, case-insensitive when verified, short lived,
16
+ and consumed after one successful verification.
17
+
18
+ ## Configuration
19
+
20
+ ```json
21
+ {
22
+ "plugins": {
23
+ "auth-code": {
24
+ "enabled": true,
25
+ "config": {
26
+ "token": "replace-with-a-long-random-token",
27
+ "host": "0.0.0.0",
28
+ "port": 8765,
29
+ "ttl_s": 120
30
+ }
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ `token` is required. The page can be opened as:
37
+
38
+ ```text
39
+ http://127.0.0.1:8765/?token=replace-with-a-long-random-token
40
+ ```
41
+
42
+ After token login, the plugin stores an in-memory HttpOnly session cookie.
43
+
44
+ ## Action
45
+
46
+ ```python
47
+ result = await context.actions.call("auth_code.verify", {"code": "A7K2Q9"})
48
+ ```
49
+
50
+ The plugin id is `auth-code`. The action id remains `auth_code.verify` because
51
+ uv-agent action ids use dotted Python-style names.
52
+
53
+ Successful verification returns:
54
+
55
+ ```json
56
+ {"ok": true, "verified": true}
57
+ ```
58
+
59
+ Failed verification returns `ok: false`, `verified: false`, and a `reason`.
@@ -0,0 +1,6 @@
1
+ uv_agent_auth_code/__init__.py,sha256=rxMiE_558Yei1eOD9ZPBo-m6OGg9SVMOztZIWtED47E,2467
2
+ uv_agent_auth_code/service.py,sha256=3iqDnyfFRxzP03zn_IAVYwSftK-JwM0MqNDmt_hWlIY,15723
3
+ uv_agent_auth_code-0.1.0.dist-info/METADATA,sha256=Js2qsGcbNuV8r9OUn1rJwilZ3CbN89pa2ona0LPsH-U,1405
4
+ uv_agent_auth_code-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ uv_agent_auth_code-0.1.0.dist-info/entry_points.txt,sha256=sXhvoIKjAonWpLDlnV695vJ6SasE4MhnlVxaNjxxjbo,57
6
+ uv_agent_auth_code-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [uv_agent.plugins]
2
+ auth-code = uv_agent_auth_code:plugin