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 +4 -0
- cli_login/broker/__init__.py +0 -0
- cli_login/broker/handler.py +310 -0
- cli_login/broker/pages.py +175 -0
- cli_login/broker/throttle.py +25 -0
- cli_login/broker/transactions.py +201 -0
- cli_login/errors.py +29 -0
- cli_login/locks.py +111 -0
- cli_login/oauth.py +143 -0
- cli_login/remote.py +208 -0
- cli_login/session.py +213 -0
- cli_login/stores/__init__.py +122 -0
- cli_login/stores/agent.py +466 -0
- cli_login/stores/kernel_keyring.py +202 -0
- cli_login/stores/macos.py +177 -0
- cli_login/stores/secret_service.py +112 -0
- cli_login/stores/windows.py +211 -0
- cli_login/target.py +101 -0
- cli_login/testing.py +258 -0
- cli_login-0.1.0.dist-info/METADATA +104 -0
- cli_login-0.1.0.dist-info/RECORD +23 -0
- cli_login-0.1.0.dist-info/WHEEL +4 -0
- cli_login-0.1.0.dist-info/licenses/LICENSE +21 -0
cli_login/__init__.py
ADDED
|
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
|