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