cli-login 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.
cli_login/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """One CLI login for every Anthus product. See docs/design.md."""
2
+
3
+ __version__ = "0.1.0"
4
+ PROTOCOL_VERSION = 1
File without changes
@@ -0,0 +1,310 @@
1
+ """The broker Lambda: hands one authorization code to the one CLI that asked for it, and never holds a token.
2
+
3
+ Two kinds of event reach it:
4
+
5
+ - **Function URL requests**: only ``POST /start`` and ``POST /poll``, called by the CLI directly. Each caller is
6
+ rate-limited, and a CLI that polls one transaction too fast is told ``slow_down``.
7
+ - **Direct invocations** with IAM, from the product's own approval pages: ``describe``, ``bind``, ``complete``
8
+ and ``deny``. They are never reachable through the function URL, so knowing a transaction id is not enough to
9
+ attach a sign-in to it.
10
+
11
+ See docs/protocol.md for every request and answer.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import ipaddress
20
+ import json
21
+ import os
22
+ import re
23
+ import secrets
24
+ import time
25
+ import unicodedata
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+ from urllib.parse import urlsplit
29
+
30
+ from .. import PROTOCOL_VERSION
31
+ from . import throttle
32
+ from .transactions import APPROVED, BOUND, DENIED, PENDING
33
+
34
+ PRIVATE_MARKER = "private-v1" # direct invocations from the product's pages carry {"cli_login": PRIVATE_MARKER}
35
+ LIFETIME_SECONDS = 600
36
+ INTERVAL_SECONDS = 5
37
+ SLOW_DOWN_SECONDS = 5
38
+ MATCHING_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ" # no vowels, so no words; no 0/O or 1/I to confuse
39
+ CHALLENGE = re.compile(r"[A-Za-z0-9_-]{43}")
40
+ HEX64 = re.compile(r"[0-9a-f]{64}")
41
+ NONCE = re.compile(r"[A-Za-z0-9_-]{16,128}")
42
+ TRANSACTION = re.compile(r"[A-Za-z0-9_-]{22}")
43
+
44
+
45
+ def sha256_hex(value: str) -> str:
46
+ # surrogatepass: a lone surrogate in caller input hashes to something that never matches, rather than raising
47
+ return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
48
+
49
+
50
+ def _https_url(value: str, name: str) -> str:
51
+ parts = urlsplit(value or "")
52
+ if parts.scheme != "https" or not parts.hostname or parts.username or parts.password or parts.query or parts.fragment:
53
+ raise ValueError(f"{name} must be an https URL")
54
+ return value
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class BrokerConfig:
59
+ client_id: str
60
+ targets: tuple[str, ...]
61
+ approval_url: str
62
+ callback_uri: str
63
+
64
+ def __post_init__(self):
65
+ if not self.client_id:
66
+ raise ValueError("the broker serves exactly one client id")
67
+ if not self.targets or not all(isinstance(t, str) and t for t in self.targets):
68
+ raise ValueError("the broker needs the targets it may sign in to")
69
+ _https_url(self.approval_url, "approval_url")
70
+ _https_url(self.callback_uri, "callback_uri")
71
+
72
+ @classmethod
73
+ def from_environment(cls, environ=os.environ) -> BrokerConfig:
74
+ return cls(
75
+ client_id=environ.get("BROKER_CLIENT_ID", ""),
76
+ targets=tuple(json.loads(environ.get("BROKER_TARGETS") or "[]")),
77
+ approval_url=environ.get("BROKER_APPROVAL_URL", ""),
78
+ callback_uri=environ.get("BROKER_CALLBACK_URI", ""),
79
+ )
80
+
81
+
82
+ def _text(value: Any) -> str:
83
+ """Display text from the requester: control characters removed, so a label cannot forge page structure."""
84
+ if not isinstance(value, str):
85
+ raise TypeError("requester fields are text")
86
+ return "".join(ch for ch in value if unicodedata.category(ch)[0] != "C").strip()
87
+
88
+
89
+ def matching_code(randbelow=secrets.randbelow) -> str:
90
+ letters = "".join(MATCHING_ALPHABET[randbelow(len(MATCHING_ALPHABET))] for _ in range(8))
91
+ return letters[:4] + "-" + letters[4:]
92
+
93
+
94
+ class Broker:
95
+ def __init__(self, store, config: BrokerConfig, *, clock=time.time, token=secrets.token_urlsafe, randbelow=secrets.randbelow):
96
+ self.store, self.config = store, config
97
+ self.clock, self.token, self.randbelow = clock, token, randbelow
98
+
99
+ def now(self) -> int:
100
+ return int(self.clock())
101
+
102
+ # --- function URL ----------------------------------------------------------------------------------------
103
+
104
+ def start(self, body: dict[str, Any], caller: str) -> tuple[int, dict[str, Any]]:
105
+ if not throttle.allowed(self.store, "start", caller, throttle.START, self.now()):
106
+ return 429, {"error": "too_many_requests", "retry_after": throttle.START.window_seconds}
107
+ try:
108
+ if body.get("protocol") != PROTOCOL_VERSION:
109
+ return 400, {"error": "unsupported_protocol", "protocol": PROTOCOL_VERSION}
110
+ if body.get("client_id") != self.config.client_id:
111
+ return 400, {"error": "invalid_client"}
112
+ if body.get("target") not in self.config.targets:
113
+ return 400, {"error": "invalid_target"}
114
+ if body.get("code_challenge_method") != "S256" or not CHALLENGE.fullmatch(str(body.get("code_challenge", ""))):
115
+ return 400, {"error": "invalid_request", "field": "code_challenge"}
116
+ if not HEX64.fullmatch(str(body.get("poll_secret_hash", ""))):
117
+ return 400, {"error": "invalid_request", "field": "poll_secret_hash"}
118
+ if not NONCE.fullmatch(str(body.get("nonce", ""))):
119
+ return 400, {"error": "invalid_request", "field": "nonce"}
120
+ requester = body.get("requester")
121
+ if not isinstance(requester, dict) or set(requester) - {"host", "version", "note"}:
122
+ return 400, {"error": "invalid_request", "field": "requester"}
123
+ label = {key: _text(value) for key, value in requester.items()}
124
+ if not label.get("host") or not label.get("version"):
125
+ return 400, {"error": "invalid_request", "field": "requester"}
126
+ except (ValueError, TypeError):
127
+ return 400, {"error": "invalid_request"}
128
+ now = self.now()
129
+ item = {
130
+ "id": self.token(16),
131
+ "state": PENDING,
132
+ "client_id": self.config.client_id,
133
+ "target": body["target"],
134
+ "code_challenge": body["code_challenge"],
135
+ "poll_hash": body["poll_secret_hash"],
136
+ "nonce": body["nonce"],
137
+ "requester": label,
138
+ "matching_code": matching_code(self.randbelow),
139
+ "created_at": now,
140
+ "expires_at": now + LIFETIME_SECONDS,
141
+ "interval": INTERVAL_SECONDS,
142
+ }
143
+ try:
144
+ created = self.store.create(item)
145
+ except Exception as error:
146
+ if _too_large(error): # DynamoDB's own 400 KB item limit, not one of ours
147
+ return 400, {"error": "invalid_request", "field": "requester"}
148
+ raise
149
+ if not created:
150
+ return 503, {"error": "temporarily_unavailable"}
151
+ return 200, {
152
+ "protocol": PROTOCOL_VERSION,
153
+ "transaction": item["id"],
154
+ "matching_code": item["matching_code"],
155
+ "approval_url": f"{self.config.approval_url}?t={item['id']}",
156
+ "interval": INTERVAL_SECONDS,
157
+ "expires_at": item["expires_at"],
158
+ }
159
+
160
+ def poll(self, body: dict[str, Any], caller: str) -> tuple[int, dict[str, Any]]:
161
+ now = self.now()
162
+ if not throttle.allowed(self.store, "poll", caller, throttle.POLL, now):
163
+ return 429, {"error": "too_many_requests", "retry_after": throttle.POLL.window_seconds}
164
+ transaction, secret = body.get("transaction"), body.get("poll_secret")
165
+ if not isinstance(transaction, str) or not TRANSACTION.fullmatch(transaction) or not isinstance(secret, str):
166
+ return 400, {"error": "invalid_request"}
167
+ item = self.store.get(transaction)
168
+ # A wrong poll secret reads exactly like a transaction that does not exist.
169
+ if item is None or not hmac.compare_digest(item["poll_hash"], sha256_hex(secret)):
170
+ return 400, {"error": "expired_token", "protocol": PROTOCOL_VERSION}
171
+ if item["expires_at"] <= now:
172
+ return 400, {"error": "expired_token", "protocol": PROTOCOL_VERSION}
173
+ last = item.get("last_poll_at")
174
+ if last is not None and now - last < item["interval"]:
175
+ slower = item["interval"] + SLOW_DOWN_SECONDS
176
+ self.store.transition(transaction, [PENDING, BOUND, APPROVED, DENIED], {"interval": slower, "last_poll_at": now},
177
+ expect={"last_poll_at": last})
178
+ return 400, {"error": "slow_down", "interval": slower, "protocol": PROTOCOL_VERSION}
179
+ if item["state"] == APPROVED:
180
+ taken = self.store.take(transaction)
181
+ if taken is None:
182
+ return 400, {"error": "expired_token", "protocol": PROTOCOL_VERSION}
183
+ return 200, {"protocol": PROTOCOL_VERSION, "code": taken["code"], "redirect_uri": self.config.callback_uri}
184
+ if item["state"] == DENIED:
185
+ return 400, {"error": "access_denied", "protocol": PROTOCOL_VERSION}
186
+ self.store.transition(transaction, [PENDING, BOUND], {"last_poll_at": now}, expect={"last_poll_at": last})
187
+ return 400, {"error": "authorization_pending", "interval": item["interval"], "protocol": PROTOCOL_VERSION}
188
+
189
+ # --- direct invocation (IAM) ------------------------------------------------------------------------------
190
+
191
+ def _live(self, transaction: Any, states) -> dict[str, Any] | None:
192
+ if not isinstance(transaction, str) or not TRANSACTION.fullmatch(transaction):
193
+ return None
194
+ item = self.store.get(transaction)
195
+ if item is None or item["state"] not in states or item["expires_at"] <= self.now():
196
+ return None
197
+ return item
198
+
199
+ def describe(self, event: dict[str, Any]) -> dict[str, Any]:
200
+ item = self._live(event.get("transaction"), [PENDING])
201
+ if item is None:
202
+ return {"ok": False, "error": "unavailable"}
203
+ return {
204
+ "ok": True,
205
+ "protocol": PROTOCOL_VERSION,
206
+ "client_id": item["client_id"],
207
+ "target": item["target"],
208
+ "requester": item["requester"],
209
+ "matching_code": item["matching_code"],
210
+ "code_challenge": item["code_challenge"],
211
+ "nonce": item["nonce"],
212
+ "redirect_uri": self.config.callback_uri,
213
+ "expires_at": item["expires_at"],
214
+ }
215
+
216
+ def bind(self, event: dict[str, Any]) -> dict[str, Any]:
217
+ nonce_hash = event.get("nonce_hash")
218
+ if self._live(event.get("transaction"), [PENDING]) is None or not HEX64.fullmatch(str(nonce_hash or "")):
219
+ return {"ok": False, "error": "unavailable"}
220
+ bound = self.store.transition(event["transaction"], [PENDING], {"state": BOUND, "nonce_hash": nonce_hash})
221
+ return {"ok": True} if bound else {"ok": False, "error": "unavailable"}
222
+
223
+ def complete(self, event: dict[str, Any]) -> dict[str, Any]:
224
+ item = self._live(event.get("transaction"), [BOUND])
225
+ nonce, code = event.get("nonce"), event.get("code")
226
+ if item is None or not isinstance(nonce, str) or not isinstance(code, str) or not code:
227
+ return {"ok": False, "error": "unavailable"}
228
+ if not hmac.compare_digest(item["nonce_hash"], sha256_hex(nonce)):
229
+ return {"ok": False, "error": "unavailable"}
230
+ done = self.store.transition(item["id"], [BOUND], {"state": APPROVED, "code": code}, expect={"nonce_hash": item["nonce_hash"]})
231
+ return {"ok": True} if done else {"ok": False, "error": "unavailable"}
232
+
233
+ def deny(self, event: dict[str, Any]) -> dict[str, Any]:
234
+ if self._live(event.get("transaction"), [PENDING, BOUND]) is None:
235
+ return {"ok": False, "error": "unavailable"}
236
+ denied = self.store.transition(event["transaction"], [PENDING, BOUND], {"state": DENIED})
237
+ return {"ok": True} if denied else {"ok": False, "error": "unavailable"}
238
+
239
+ # --- dispatch ---------------------------------------------------------------------------------------------
240
+
241
+ def handle(self, event: dict[str, Any]) -> dict[str, Any]:
242
+ # Anything shaped like a function URL request is only ever routed to /start or /poll. A private operation
243
+ # needs an explicit marker and no request context at all, which a function URL request always has.
244
+ if "requestContext" in event:
245
+ http = (event.get("requestContext") or {}).get("http")
246
+ return self._http(event, http) if isinstance(http, dict) else _response(404, {"error": "not_found"})
247
+ if event.get("cli_login") != PRIVATE_MARKER:
248
+ return {"ok": False, "error": "unknown_operation"}
249
+ operation = {"describe": self.describe, "bind": self.bind, "complete": self.complete, "deny": self.deny}.get(
250
+ event.get("operation")
251
+ )
252
+ return operation(event) if operation else {"ok": False, "error": "unknown_operation"}
253
+
254
+ def _http(self, event: dict[str, Any], http: dict[str, Any]) -> dict[str, Any]:
255
+ route = {"/start": self.start, "/poll": self.poll}.get(event.get("rawPath"))
256
+ if http.get("method") != "POST" or route is None:
257
+ return _response(404, {"error": "not_found"})
258
+ try:
259
+ raw = event.get("body") or ""
260
+ if event.get("isBase64Encoded"):
261
+ raw = base64.b64decode(raw).decode()
262
+ body = json.loads(raw)
263
+ except (ValueError, UnicodeDecodeError, RecursionError):
264
+ return _response(400, {"error": "invalid_request"})
265
+ if not isinstance(body, dict):
266
+ return _response(400, {"error": "invalid_request"})
267
+ try:
268
+ status, answer = route(body, caller_bucket(str(http.get("sourceIp") or "unknown")))
269
+ except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
270
+ return _response(400, {"error": "invalid_request"})
271
+ return _response(status, answer)
272
+
273
+
274
+ def _too_large(error: Exception) -> bool:
275
+ detail = (getattr(error, "response", None) or {}).get("Error") or {}
276
+ return detail.get("Code") == "ValidationException" and "size" in str(detail.get("Message", "")).lower()
277
+
278
+
279
+ def caller_bucket(address: str) -> str:
280
+ """The rate-limit bucket for a caller: an IPv4 address, or an IPv6 /64, which one host can fill freely."""
281
+ try:
282
+ ip = ipaddress.ip_address(address)
283
+ except ValueError:
284
+ return address
285
+ if ip.version == 6:
286
+ if ip.ipv4_mapped is not None:
287
+ return str(ip.ipv4_mapped)
288
+ return str(ipaddress.ip_network(f"{ip}/64", strict=False))
289
+ return str(ip)
290
+
291
+
292
+ def _response(status: int, body: dict[str, Any]) -> dict[str, Any]:
293
+ headers = {"content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff"}
294
+ if status == 429:
295
+ headers["retry-after"] = str(body.get("retry_after", 60))
296
+ return {"statusCode": status, "headers": headers, "body": json.dumps(body)}
297
+
298
+
299
+ _broker: Broker | None = None
300
+
301
+
302
+ def lambda_handler(event, context): # pragma: no cover - wiring only; Broker is specified directly
303
+ global _broker
304
+ if _broker is None:
305
+ import boto3
306
+
307
+ from .transactions import DynamoTransactions
308
+
309
+ _broker = Broker(DynamoTransactions(boto3.client("dynamodb"), os.environ["BROKER_TABLE"]), BrokerConfig.from_environment())
310
+ return _broker.handle(event)
@@ -0,0 +1,175 @@
1
+ """The approval and callback pages a product serves on its own domain, framework-neutral.
2
+
3
+ A product mounts ``ApprovalPages.approve`` at ``/cli/approve`` (GET and POST) and ``ApprovalPages.callback`` at
4
+ ``/cli/callback`` (GET), turning each ``Page`` into its framework's response. ``invoke`` calls the product's broker
5
+ Lambda directly with IAM and returns its answer; the pages never reach the broker's function URL.
6
+
7
+ - ``/cli/approve`` shows who is asking, the matching code, the target and what the session can do. Approving
8
+ needs the person to confirm the code matches, a CSRF check, and binds the transaction to a nonce N kept in a
9
+ first-party cookie before sending the browser to the sign-in provider.
10
+ - ``/cli/callback`` checks that the state's N matches the cookie, then hands the code to the broker.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import html
18
+ import secrets
19
+ from collections.abc import Callable, Mapping
20
+ from dataclasses import dataclass, field
21
+ from urllib.parse import urlencode, urlsplit
22
+
23
+ from .handler import PRIVATE_MARKER
24
+
25
+ # __Secure-: browsers accept these cookies only over HTTPS with the Secure attribute, so a plain-HTTP page or a
26
+ # sibling subdomain over HTTP cannot plant one.
27
+ CSRF_COOKIE = "__Secure-cli_login_csrf"
28
+ NONCE_COOKIE = "__Secure-cli_login_nonce"
29
+ COOKIE_SECONDS = 600
30
+
31
+
32
+ @dataclass
33
+ class Page:
34
+ status: int
35
+ body: str = ""
36
+ headers: list[tuple[str, str]] = field(default_factory=list)
37
+
38
+
39
+ def _cookie(name: str, value: str, path: str, *, same_site: str, max_age: int = COOKIE_SECONDS) -> tuple[str, str]:
40
+ return "Set-Cookie", f"{name}={value}; Path={path}; Max-Age={max_age}; HttpOnly; Secure; SameSite={same_site}"
41
+
42
+
43
+ def _cleared(name: str, path: str) -> tuple[str, str]:
44
+ return _cookie(name, "", path, same_site="Lax", max_age=0)
45
+
46
+
47
+ def _headers(form_action: str = "'self'") -> list[tuple[str, str]]:
48
+ # form-action also governs where the approve POST's 303 may go, so it must allow the sign-in provider's origin.
49
+ return [
50
+ ("Content-Type", "text/html; charset=utf-8"),
51
+ ("Cache-Control", "no-store"),
52
+ ("Referrer-Policy", "no-referrer"),
53
+ ("X-Frame-Options", "DENY"),
54
+ ("X-Content-Type-Options", "nosniff"),
55
+ ("Content-Security-Policy", f"default-src 'none'; style-src 'unsafe-inline'; form-action {form_action}; frame-ancestors 'none'"),
56
+ ]
57
+
58
+
59
+ BASE_HEADERS = _headers()
60
+
61
+
62
+ def _document(title: str, body: str) -> str:
63
+ return (
64
+ "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
65
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
66
+ f"<title>{html.escape(title)}</title>"
67
+ "<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:2rem auto;padding:0 1rem;line-height:1.5}"
68
+ ".code{font-size:2rem;font-weight:700;letter-spacing:.15em;font-family:ui-monospace,monospace}"
69
+ "dt{font-weight:600}dd{margin:0 0 .75rem;overflow-wrap:anywhere}.note{max-height:6rem;overflow:auto}button{font-size:1rem;padding:.5rem 1rem;margin-right:.5rem}</style>"
70
+ f"</head><body>{body}</body></html>"
71
+ )
72
+
73
+
74
+ def _message(status: int, title: str, text: str, headers=()) -> Page:
75
+ return Page(status, _document(title, f"<h1>{html.escape(title)}</h1><p>{html.escape(text)}</p>"), [*BASE_HEADERS, *headers])
76
+
77
+
78
+ def _gone() -> Page:
79
+ return _message(410, "This sign-in link is no longer valid", "It expired, was already used, or was denied. Start the sign-in again from the CLI.")
80
+
81
+
82
+ class ApprovalPages:
83
+ def __init__(
84
+ self,
85
+ invoke: Callable[[dict], Mapping],
86
+ *,
87
+ authorize_endpoint: str,
88
+ product: str,
89
+ privileges: str,
90
+ scope: str = "openid email profile",
91
+ identity_provider: str | None = None,
92
+ token=secrets.token_urlsafe,
93
+ ):
94
+ self.invoke, self.authorize_endpoint, self.product = (lambda payload: invoke({**payload, "cli_login": PRIVATE_MARKER})), authorize_endpoint, product
95
+ parts = urlsplit(authorize_endpoint)
96
+ self.form_action = f"'self' {parts.scheme}://{parts.netloc}"
97
+ self.privileges, self.scope, self.identity_provider, self.token = privileges, scope, identity_provider, token
98
+
99
+ def approve(self, method: str, query: Mapping[str, str], form: Mapping[str, str], cookies: Mapping[str, str]) -> Page:
100
+ if method == "GET":
101
+ return self._show(query.get("t", ""))
102
+ if method != "POST":
103
+ return _message(405, "Not allowed", "Use the approval page.")
104
+ csrf = form.get("csrf", "")
105
+ if not csrf or not hmac.compare_digest(csrf, cookies.get(CSRF_COOKIE, "")):
106
+ return _message(403, "This page expired", "Open the approval link again.")
107
+ transaction = form.get("t", "")
108
+ if form.get("action") == "deny":
109
+ self.invoke({"operation": "deny", "transaction": transaction})
110
+ return _message(200, "Sign-in denied", "The waiting CLI will not be signed in.", [_cleared(CSRF_COOKIE, "/cli/approve")])
111
+ if form.get("action") != "approve" or form.get("confirm") != "yes":
112
+ return _message(400, "Confirm the code", "Check that the code matches the one the CLI showed, then approve.")
113
+ described = self.invoke({"operation": "describe", "transaction": transaction})
114
+ if not described.get("ok"):
115
+ return _gone()
116
+ nonce = self.token(32)
117
+ if not self.invoke({"operation": "bind", "transaction": transaction, "nonce_hash": hashlib.sha256(nonce.encode()).hexdigest()}).get("ok"):
118
+ return _gone()
119
+ query_string = {
120
+ "client_id": described["client_id"],
121
+ "response_type": "code",
122
+ "redirect_uri": described["redirect_uri"],
123
+ "scope": self.scope,
124
+ "state": f"{transaction}.{nonce}",
125
+ "nonce": described["nonce"],
126
+ "code_challenge": described["code_challenge"],
127
+ "code_challenge_method": "S256",
128
+ }
129
+ if self.identity_provider:
130
+ query_string["identity_provider"] = self.identity_provider
131
+ return Page(303, "", [
132
+ *_headers(self.form_action),
133
+ ("Location", f"{self.authorize_endpoint}?{urlencode(query_string)}"),
134
+ _cookie(NONCE_COOKIE, nonce, "/cli/callback", same_site="Lax"),
135
+ _cleared(CSRF_COOKIE, "/cli/approve"),
136
+ ])
137
+
138
+ def _show(self, transaction: str) -> Page:
139
+ described = self.invoke({"operation": "describe", "transaction": transaction})
140
+ if not described.get("ok"):
141
+ return _gone()
142
+ csrf = self.token(32)
143
+ requester = described.get("requester") or {}
144
+ e = html.escape
145
+ note = f"<dt>Note</dt><dd class=\"note\">{e(requester['note'])}</dd>" if requester.get("note") else ""
146
+ body = (
147
+ f"<h1>Approve a {e(self.product)} CLI sign-in?</h1>"
148
+ "<p><strong>The CLI will be signed in as you.</strong> Approve only if you started this sign-in, or an agent "
149
+ "you run asked you to, and the code below matches the one it showed you.</p>"
150
+ f"<p>Code</p><p class=\"code\">{e(described['matching_code'])}</p>"
151
+ f"<dl><dt>Requested by</dt><dd>{e(requester.get('host', ''))}, CLI {e(requester.get('version', ''))}</dd>{note}"
152
+ f"<dt>Signs in to</dt><dd>{e(described['target'])}</dd>"
153
+ f"<dt>It will be able to</dt><dd>{e(self.privileges)}</dd></dl>"
154
+ "<form method=\"post\" action=\"/cli/approve\">"
155
+ f"<input type=\"hidden\" name=\"t\" value=\"{e(transaction)}\"><input type=\"hidden\" name=\"csrf\" value=\"{e(csrf)}\">"
156
+ "<p><label><input type=\"checkbox\" name=\"confirm\" value=\"yes\" required> "
157
+ "This code matches the one the CLI showed</label></p>"
158
+ "<button name=\"action\" value=\"approve\">Approve</button>"
159
+ "<button name=\"action\" value=\"deny\" formnovalidate>Deny</button></form>"
160
+ )
161
+ return Page(200, _document(f"Approve a {self.product} CLI sign-in", body),
162
+ [*_headers(self.form_action), _cookie(CSRF_COOKIE, csrf, "/cli/approve", same_site="Strict")])
163
+
164
+ def callback(self, query: Mapping[str, str], cookies: Mapping[str, str]) -> Page:
165
+ transaction, _, nonce = query.get("state", "").partition(".")
166
+ clear = [_cleared(NONCE_COOKIE, "/cli/callback")]
167
+ if not transaction or not nonce or not hmac.compare_digest(nonce, cookies.get(NONCE_COOKIE, "")):
168
+ return _message(400, "This sign-in could not be checked",
169
+ "Finish the sign-in in the same browser you approved it in, or start again from the CLI.", clear)
170
+ if query.get("error") or not query.get("code"):
171
+ self.invoke({"operation": "deny", "transaction": transaction})
172
+ return _message(200, "Sign-in cancelled", "The waiting CLI will not be signed in.", clear)
173
+ if not self.invoke({"operation": "complete", "transaction": transaction, "nonce": nonce, "code": query["code"]}).get("ok"):
174
+ return _gone()
175
+ return _message(200, "Sign-in approved", "You can close this page. The waiting CLI is signing in now.", clear)
@@ -0,0 +1,25 @@
1
+ """Per-caller rate limits for the broker's public routes.
2
+
3
+ The broker's ``/start`` and ``/poll`` take calls from anyone who can reach the function URL, so each caller (its
4
+ source address) gets a fixed number of calls per window. A CLI that polls one transaction faster than its
5
+ interval is told ``slow_down`` separately, by the poll itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Limit:
15
+ calls: int
16
+ window_seconds: int
17
+
18
+
19
+ START = Limit(calls=10, window_seconds=60)
20
+ POLL = Limit(calls=120, window_seconds=60)
21
+
22
+
23
+ def allowed(store, route: str, caller: str, limit: Limit, now: int) -> bool:
24
+ """Count this call and say whether the caller is still within the route's limit."""
25
+ return store.count(f"{route}#{caller}", now, limit.window_seconds) <= limit.calls