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/remote.py ADDED
@@ -0,0 +1,208 @@
1
+ """Remote sign-in: a CLI with no browser gets a session that a person approves from another device.
2
+
3
+ The CLI keeps two secrets and sends only their hashes: the PKCE verifier V (the broker sees ``S256(V)``) and the
4
+ poll secret P (the broker sees ``sha256(P)``). The person opens the approval URL, checks the matching code and
5
+ signs in; the broker then holds an authorization code that is useless without V, and hands it only to a poll that
6
+ knows P. The CLI exchanges the code itself and passes the token response to the session store.
7
+
8
+ See docs/protocol.md.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import json
16
+ import math
17
+ import secrets
18
+ import socket
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from collections.abc import Callable, Mapping
24
+ from typing import Any
25
+
26
+ from . import PROTOCOL_VERSION, __version__
27
+ from .errors import AuthError, OAuthError
28
+
29
+
30
+ class RemoteLoginError(OAuthError):
31
+ """A remote sign-in that did not finish. ``transient`` says whether trying again later may work."""
32
+
33
+ def __init__(self, code: str, message: str, *, transient: bool = False):
34
+ self.code = code
35
+ self.transient = transient
36
+ AuthError.__init__(self, message)
37
+
38
+
39
+ def default_http(method: str, url: str, *, json_body=None, form=None, timeout: float = 30) -> tuple[int, dict[str, Any], dict[str, str]]:
40
+ """One HTTPS request; answers (status, JSON body, headers). Network failures raise OSError."""
41
+ if json_body is not None:
42
+ data, content_type = json.dumps(json_body).encode(), "application/json"
43
+ else:
44
+ data, content_type = urllib.parse.urlencode(form or {}).encode(), "application/x-www-form-urlencoded"
45
+ request = urllib.request.Request(url, data=data, method=method, headers={"content-type": content_type, "accept": "application/json"})
46
+ try:
47
+ with urllib.request.urlopen(request, timeout=timeout) as response:
48
+ status, raw, headers = response.status, response.read(), dict(response.headers)
49
+ except urllib.error.HTTPError as error:
50
+ status, raw, headers = error.code, error.read(), dict(error.headers or {})
51
+ try:
52
+ body = json.loads(raw or b"{}")
53
+ except ValueError:
54
+ body = {}
55
+ return status, body if isinstance(body, dict) else {}, {key.lower(): value for key, value in headers.items()}
56
+
57
+
58
+ def _b64url(data: bytes) -> str:
59
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
60
+
61
+
62
+ def _https(value: Any, what: str) -> str:
63
+ parts = urllib.parse.urlsplit(value if isinstance(value, str) else "")
64
+ if parts.scheme != "https" or not parts.hostname or parts.username or parts.password:
65
+ raise RemoteLoginError("invalid_broker_answer", f"The broker answered an unusable {what}.")
66
+ return value
67
+
68
+
69
+ MAX_INTERVAL = 60 # seconds between polls, whatever the broker says
70
+ MAX_WAIT = 900 # seconds a CLI waits for an approval, whatever the broker says
71
+
72
+
73
+ def _clamp(value: Any, default: float, low: float, high: float | None) -> float:
74
+ try:
75
+ number = float(value)
76
+ except (TypeError, ValueError):
77
+ number = default
78
+ if math.isnan(number):
79
+ number = default
80
+ number = max(number, low)
81
+ return min(number, high) if high is not None else number
82
+
83
+
84
+ def _claims(token: Any) -> dict[str, Any]:
85
+ """The payload of a JWT the CLI received straight from the token endpoint over TLS (no signature check here)."""
86
+ try:
87
+ payload = token.split(".")[1]
88
+ return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
89
+ except (AttributeError, IndexError, ValueError):
90
+ raise RemoteLoginError("invalid_token", "The sign-in provider answered an unreadable token.") from None
91
+
92
+
93
+ def remote_login(
94
+ target,
95
+ store_token_set: Callable[[Mapping[str, Any]], Any],
96
+ *,
97
+ http=default_http,
98
+ out: Callable[[str], None] = print,
99
+ clock: Callable[[], float] = time.time,
100
+ sleep: Callable[[float], None] = time.sleep,
101
+ host: str | None = None,
102
+ note: str | None = None,
103
+ expected_email: str | None = None,
104
+ token=secrets.token_urlsafe,
105
+ ):
106
+ """Sign in through the target's broker and return what ``store_token_set`` returns.
107
+
108
+ ``target`` supplies ``client_id``, ``broker_endpoint``, ``remote_callback_uri``, ``token_endpoint`` and
109
+ ``api_endpoint``, which names the binding to the broker and on the approval page. The broker's ``redirect_uri``
110
+ must equal ``remote_callback_uri``, and its approval URL must be on that origin. Nothing secret is printed: only the approval URL and the
111
+ matching code.
112
+
113
+ Whoever approves first owns the session, and the approval URL and code are often relayed through chat. Pass
114
+ ``expected_email`` to refuse, and store nothing, when the person who approved is someone else. The signed-in
115
+ identity is always printed.
116
+ """
117
+ broker = getattr(target, "broker_endpoint", None)
118
+ if not broker:
119
+ raise RemoteLoginError("remote_unavailable", "This target has no remote sign-in broker; sign in on a machine with a browser.")
120
+ broker = _https(broker, "broker endpoint").rstrip("/")
121
+ remote_callback = getattr(target, "remote_callback_uri", None)
122
+ if not remote_callback:
123
+ raise RemoteLoginError("remote_unavailable", "This target names no remote callback; sign in on a machine with a browser.")
124
+ remote_callback = _https(remote_callback, "remote callback URI")
125
+ expected_origin = urllib.parse.urlsplit(remote_callback)
126
+ verifier, poll_secret, nonce = token(64), token(32), token(24)
127
+ requester = {"host": host or socket.gethostname(), "version": __version__}
128
+ if note:
129
+ requester["note"] = note
130
+ try:
131
+ status, started, _ = http("POST", broker + "/start", json_body={
132
+ "protocol": PROTOCOL_VERSION,
133
+ "client_id": target.client_id,
134
+ "target": target.api_endpoint,
135
+ "code_challenge": _b64url(hashlib.sha256(verifier.encode()).digest()),
136
+ "code_challenge_method": "S256",
137
+ "poll_secret_hash": hashlib.sha256(poll_secret.encode()).hexdigest(),
138
+ "nonce": nonce,
139
+ "requester": requester,
140
+ })
141
+ except OSError:
142
+ raise RemoteLoginError("broker_unreachable", "The sign-in broker could not be reached; try again.", transient=True) from None
143
+ if status == 429:
144
+ raise RemoteLoginError("too_many_requests", "Too many sign-in attempts from this machine; wait a minute and try again.", transient=True)
145
+ if status != 200 or started.get("protocol") != PROTOCOL_VERSION:
146
+ raise RemoteLoginError(started.get("error") or "start_refused", "The sign-in broker refused to start a sign-in.")
147
+ url = _https(started.get("approval_url"), "approval URL")
148
+ transaction, code = started.get("transaction"), started.get("matching_code")
149
+ if not isinstance(transaction, str) or not isinstance(code, str):
150
+ raise RemoteLoginError("invalid_broker_answer", "The broker answered an unusable transaction.")
151
+ url_origin = urllib.parse.urlsplit(url)
152
+ if (url_origin.scheme, url_origin.netloc) != (expected_origin.scheme, expected_origin.netloc):
153
+ raise RemoteLoginError("invalid_broker_answer", "The broker pointed at an approval page on another site.")
154
+ interval = _clamp(started.get("interval"), 5, 1, MAX_INTERVAL)
155
+ expires_at = min(_clamp(started.get("expires_at"), clock() + 600, 0, None), clock() + MAX_WAIT)
156
+ out(f"To approve this sign-in, open {url}\nand check that the page shows the code {code}.")
157
+
158
+ while True:
159
+ if clock() >= expires_at:
160
+ raise RemoteLoginError("expired_token", "The approval link expired; start the sign-in again.")
161
+ sleep(interval)
162
+ try:
163
+ status, answer, headers = http("POST", broker + "/poll", json_body={"transaction": transaction, "poll_secret": poll_secret})
164
+ except OSError:
165
+ continue # polling is safe to repeat; the code is handed over only once, to a poll that knows P
166
+ if status == 200:
167
+ break
168
+ error = answer.get("error")
169
+ if error == "authorization_pending":
170
+ continue
171
+ if error == "slow_down":
172
+ interval = min(max(_clamp(answer.get("interval"), interval + 5, 1, MAX_INTERVAL), interval + 1), MAX_INTERVAL)
173
+ elif status == 429:
174
+ sleep(_clamp(headers.get("retry-after") or answer.get("retry_after"), 60, 1, MAX_INTERVAL))
175
+ elif error == "access_denied":
176
+ raise RemoteLoginError("access_denied", "The sign-in was denied on the approval page.")
177
+ elif error == "expired_token":
178
+ raise RemoteLoginError("expired_token", "The approval link expired or was already used; start the sign-in again.")
179
+ elif status < 500:
180
+ raise RemoteLoginError(error or "poll_refused", "The sign-in broker refused the sign-in.")
181
+
182
+ authorization_code, redirect_uri = answer.get("code"), _https(answer.get("redirect_uri"), "redirect URI")
183
+ if redirect_uri != remote_callback:
184
+ raise RemoteLoginError("invalid_broker_answer", "The broker answered a callback this target does not use.")
185
+ if not isinstance(authorization_code, str) or not authorization_code:
186
+ raise RemoteLoginError("invalid_broker_answer", "The broker answered without a code.")
187
+ try:
188
+ status, tokens, _ = http("POST", _https(target.token_endpoint, "token endpoint"), form={
189
+ "grant_type": "authorization_code",
190
+ "client_id": target.client_id,
191
+ "code": authorization_code,
192
+ "redirect_uri": redirect_uri,
193
+ "code_verifier": verifier,
194
+ })
195
+ except OSError:
196
+ # The code is single use and may already be spent; a retry cannot be known to be safe, so start again.
197
+ raise RemoteLoginError("exchange_uncertain", "The sign-in provider did not answer; start the sign-in again.") from None
198
+ if status != 200:
199
+ raise RemoteLoginError(str(tokens.get("error") or "exchange_refused"), "The sign-in provider refused the code; start the sign-in again.")
200
+ claims = _claims(tokens.get("id_token"))
201
+ if claims.get("nonce") != nonce:
202
+ raise RemoteLoginError("nonce_mismatch", "The sign-in answered tokens for a different request; nothing was saved.")
203
+ email = claims.get("email") if isinstance(claims.get("email"), str) else None
204
+ if expected_email is not None and (email or "").casefold() != expected_email.casefold():
205
+ raise RemoteLoginError("unexpected_identity", "Someone other than the expected person approved this sign-in; nothing was saved.")
206
+ record = store_token_set(tokens)
207
+ out(f"Signed in as {email or claims.get('sub', 'an unknown identity')}.")
208
+ return record
cli_login/session.py ADDED
@@ -0,0 +1,213 @@
1
+ """A renewable session: login, renewal, status and logout with honest revocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import math
8
+ import secrets
9
+ import threading
10
+ import time
11
+ from contextlib import contextmanager
12
+
13
+ from .errors import AuthError, OAuthError
14
+ from .locks import default_state_dir, key_lock
15
+ from .oauth import HTTPProvider, authorization_url, pkce_pair, receive_code
16
+ from .stores import open_store
17
+ from .target import Target
18
+
19
+
20
+ class Session:
21
+ def __init__(
22
+ self,
23
+ target: Target,
24
+ *,
25
+ store=None,
26
+ provider=None,
27
+ clock=time.time,
28
+ receive=receive_code,
29
+ state_dir=None,
30
+ login_command: str = "login",
31
+ identity_provider: str | None = None,
32
+ ):
33
+ self.target = target
34
+ self.store = store if store is not None else open_store(target)
35
+ self.provider = provider if provider is not None else HTTPProvider()
36
+ self.clock = clock
37
+ self.receive = receive
38
+ self.state_dir = state_dir if state_dir is not None else default_state_dir(target.namespace)
39
+ self.login_command = login_command
40
+ self.identity_provider = identity_provider
41
+ self._token = None
42
+ self._expires = 0.0
43
+ self._subject = None
44
+ self._guard = threading.RLock()
45
+ self._depth = 0
46
+
47
+ @contextmanager
48
+ def _hold(self):
49
+ """The key's lock, re-entrant within this session so locked callers can store a token set."""
50
+ with self._guard:
51
+ if self._depth:
52
+ self._depth += 1
53
+ try:
54
+ yield
55
+ finally:
56
+ self._depth -= 1
57
+ return
58
+ with key_lock(self.state_dir, self.target.key):
59
+ self._depth = 1
60
+ try:
61
+ yield
62
+ finally:
63
+ self._depth = 0
64
+
65
+ def _record(self):
66
+ raw = self.store.get(self.target.key)
67
+ if raw is None:
68
+ raise AuthError(f"No session; run `{self.login_command}`.")
69
+ try:
70
+ record = json.loads(raw)
71
+ if (
72
+ not isinstance(record.get("refresh_token"), str)
73
+ or not record["refresh_token"]
74
+ or not isinstance(record.get("subject"), str)
75
+ or not record["subject"]
76
+ or not isinstance(record.get("label"), str)
77
+ ):
78
+ raise ValueError()
79
+ record["created_at"] = float(record["created_at"])
80
+ if not math.isfinite(record["created_at"]):
81
+ raise ValueError()
82
+ return record
83
+ except (KeyError, ValueError, AttributeError, TypeError):
84
+ raise AuthError(f"Stored session is invalid; run `{self.login_command}` to replace it.") from None
85
+
86
+ def store_token_set(self, response, *, previous=None) -> dict:
87
+ """Check a token response against the target and save the session under the key's lock.
88
+
89
+ Returns {subject, label, created_at}. Raises AuthError, saving nothing, on any mismatch.
90
+ """
91
+ try:
92
+ value = response["id_token"]
93
+ claims = json.loads(base64.urlsafe_b64decode(value.split(".")[1] + "==="))
94
+ expected = self.target
95
+ if (
96
+ claims["iss"] != expected.issuer
97
+ or claims["aud"] != expected.client_id
98
+ or claims["token_use"] != "id"
99
+ or not claims["sub"]
100
+ or (previous and claims["sub"] != previous["subject"])
101
+ or float(claims["exp"]) <= self.clock()
102
+ ):
103
+ raise ValueError()
104
+ expires = min(float(claims["exp"]), self.clock() + float(response["expires_in"]))
105
+ if (
106
+ not math.isfinite(expires)
107
+ or not math.isfinite(float(response["expires_in"]))
108
+ or expires <= self.clock()
109
+ ):
110
+ raise ValueError()
111
+ refresh = response.get("refresh_token") or (previous or {}).get("refresh_token")
112
+ if not isinstance(refresh, str) or not refresh:
113
+ raise ValueError()
114
+ except (KeyError, ValueError, TypeError, IndexError, AttributeError):
115
+ raise AuthError(
116
+ "Provider token does not match the configured identity or client. No new session was saved."
117
+ ) from None
118
+ # Tokens arrive only from the configured TLS token endpoint; the API verifies signature and
119
+ # permissions. Local decoded claims are display and binding only.
120
+ record = {
121
+ "subject": claims["sub"],
122
+ "label": str(claims.get("email") or claims["sub"]),
123
+ "refresh_token": refresh,
124
+ "created_at": (previous or {}).get("created_at", self.clock()),
125
+ }
126
+ with self._hold():
127
+ self.store.set(self.target.key, json.dumps(record))
128
+ self._token, self._expires, self._subject = value, expires, record["subject"]
129
+ return {"subject": record["subject"], "label": record["label"], "created_at": record["created_at"]}
130
+
131
+ def login(self) -> dict:
132
+ """Local browser sign-in on the target's loopback callback."""
133
+ if not self.target.has_loopback_callback:
134
+ raise AuthError("Local sign-in needs a loopback callback; this target's callback is not one.")
135
+ state = secrets.token_urlsafe(32)
136
+ verifier, challenge = pkce_pair()
137
+ url = authorization_url(self.target, state=state, challenge=challenge, identity_provider=self.identity_provider)
138
+ code = self.receive(self.target.callback_uri, url, state, 180.0)
139
+ with self._hold():
140
+ response = self.provider.post(
141
+ self.target.token_endpoint,
142
+ {
143
+ "grant_type": "authorization_code",
144
+ "client_id": self.target.client_id,
145
+ "code": code,
146
+ "redirect_uri": self.target.callback_uri,
147
+ "code_verifier": verifier,
148
+ },
149
+ )
150
+ return self.store_token_set(response)
151
+
152
+ def headers(self, endpoint):
153
+ if endpoint != self.target.api_endpoint:
154
+ raise AuthError("Endpoint does not match the signed-in target; sign in to the target that owns it.")
155
+ with self._hold():
156
+ record = self._record() # Check even with a cached access token: local logout must win.
157
+ if self._subject is not None and self._subject != record["subject"]:
158
+ self._token = None # Another process explicitly signed in as a different person.
159
+ if not self._token or self._expires <= self.clock() + 60:
160
+ try:
161
+ response = self.provider.post(
162
+ self.target.token_endpoint,
163
+ {
164
+ "grant_type": "refresh_token",
165
+ "client_id": self.target.client_id,
166
+ "refresh_token": record["refresh_token"],
167
+ },
168
+ )
169
+ except OAuthError as error:
170
+ if error.code == "invalid_grant":
171
+ self.store.delete(self.target.key)
172
+ self._token = None
173
+ raise OAuthError(error.code, transient=error.transient, login_command=self.login_command) from None
174
+ self.store_token_set(response, previous=record)
175
+ return {"content-type": "application/json", "Authorization": self._token}
176
+
177
+ def status(self) -> dict:
178
+ try:
179
+ record = self._record()
180
+ except AuthError:
181
+ # Locked or unavailable stores stay errors, never a fabricated signed-out state.
182
+ if self.store.get(self.target.key) is not None:
183
+ raise
184
+ return {"signed_in": False}
185
+ return {
186
+ "signed_in": True,
187
+ "subject": record["subject"],
188
+ "label": record["label"],
189
+ "access_expires_at": self._expires or None,
190
+ "elapsed_session_seconds": max(0, self.clock() - record["created_at"]),
191
+ }
192
+
193
+ def logout(self) -> dict:
194
+ remote = "not_needed"
195
+ with self._hold():
196
+ raw = self.store.get(self.target.key)
197
+ try:
198
+ if raw:
199
+ remote = "unconfirmed"
200
+ record = self._record()
201
+ self.provider.post(
202
+ self.target.revoke_endpoint,
203
+ {"client_id": self.target.client_id, "token": record["refresh_token"]},
204
+ )
205
+ remote = "confirmed"
206
+ except AuthError:
207
+ pass
208
+ finally:
209
+ self.store.delete(self.target.key)
210
+ self._token = None
211
+ self._expires = 0.0
212
+ # An access token the API already accepted stays valid until it expires.
213
+ return {"local_session_removed": True, "remote_revocation": remote}
@@ -0,0 +1,122 @@
1
+ """Secure session stores. Choosing one fails closed; there is no plaintext fallback.
2
+
3
+ Order of choice:
4
+ - macOS: the Keychain.
5
+ - Linux: Secret Service, then the kernel keyring, then a running memory agent.
6
+ - Windows: Credential Manager.
7
+
8
+ Set ``CLI_LOGIN_STORE`` (or pass ``backend=``) to one of ``keychain``, ``secret-service``,
9
+ ``kernel-keyring``, ``agent`` or ``credential-manager`` to choose explicitly. Any other value, a file or
10
+ plaintext included, is refused. Backends load lazily so importing the package needs no optional dependency.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+
18
+ from ..errors import AuthError, StoreUnavailable
19
+ from ..target import Target
20
+
21
+ ENV_STORE = "CLI_LOGIN_STORE"
22
+
23
+
24
+ def _keychain(target, state_dir, environ):
25
+ from .macos import MacOSKeychainStore
26
+
27
+ return MacOSKeychainStore(target.namespace)
28
+
29
+
30
+ def _secret_service(target, state_dir, environ):
31
+ from .secret_service import SecretServiceStore
32
+
33
+ return SecretServiceStore(target.namespace)
34
+
35
+
36
+ def _kernel_keyring(target, state_dir, environ):
37
+ from .kernel_keyring import KernelKeyringStore
38
+
39
+ return KernelKeyringStore(target.namespace)
40
+
41
+
42
+ def _agent(target, state_dir, environ):
43
+ from .agent import AgentStore
44
+
45
+ return AgentStore(target.namespace, state_dir=state_dir, environ=environ)
46
+
47
+
48
+ def _credential_manager(target, state_dir, environ):
49
+ from .windows import WindowsCredentialStore
50
+
51
+ return WindowsCredentialStore(target.namespace)
52
+
53
+
54
+ # name -> (factory, platform prefix). Tests may replace entries.
55
+ BACKENDS = {
56
+ "keychain": (_keychain, "darwin"),
57
+ "secret-service": (_secret_service, "linux"),
58
+ "kernel-keyring": (_kernel_keyring, "linux"),
59
+ "agent": (_agent, ("linux", "darwin")),
60
+ "credential-manager": (_credential_manager, "win32"),
61
+ }
62
+ DEFAULT_ORDER = {
63
+ "darwin": ["keychain"],
64
+ "linux": ["secret-service", "kernel-keyring", "agent"],
65
+ "win32": ["credential-manager"],
66
+ }
67
+
68
+
69
+ def _supported(name: str, platform: str) -> bool:
70
+ return platform.startswith(BACKENDS[name][1])
71
+
72
+
73
+ def _try(name, target, state_dir, environ):
74
+ store = BACKENDS[name][0](target, state_dir, environ)
75
+ if hasattr(store, "probe"):
76
+ store.probe()
77
+ return store
78
+
79
+
80
+ def _guidance(target: Target, reasons: list[tuple[str, str]], state_dir) -> AuthError:
81
+ from .agent import agent_command
82
+
83
+ lines = ["No secure credential store is available on this machine."]
84
+ lines += [f"- {name}: {reason}" for name, reason in reasons]
85
+ lines.append(
86
+ f"On a cloud machine, start the memory agent: {agent_command(target.namespace, state_dir=state_dir)} "
87
+ "then sign in again. Elsewhere, install the Linux extra "
88
+ '(pip install "cli-login[linux]") and unlock your desktop keyring. '
89
+ "Plaintext storage is never used."
90
+ )
91
+ return AuthError("\n".join(lines))
92
+
93
+
94
+ def open_store(target: Target, *, backend: str | None = None, state_dir=None, environ=None, platform: str | None = None):
95
+ """The secure store for this machine, or an AuthError that says exactly what to do."""
96
+ environ = os.environ if environ is None else environ
97
+ platform = platform or sys.platform
98
+ chosen = backend or environ.get(ENV_STORE) or None
99
+ if chosen:
100
+ if chosen not in BACKENDS:
101
+ raise AuthError(
102
+ f"Unknown credential store {chosen!r}; choose one of {', '.join(BACKENDS)}. "
103
+ "Plaintext storage is never used."
104
+ )
105
+ if not _supported(chosen, platform):
106
+ raise AuthError(f"The {chosen} store is not available on this platform.")
107
+ try:
108
+ return _try(chosen, target, state_dir, environ)
109
+ except StoreUnavailable as error:
110
+ raise AuthError(str(error)) from None
111
+ order = next((names for prefix, names in DEFAULT_ORDER.items() if platform.startswith(prefix)), None)
112
+ if order is None:
113
+ raise AuthError("No secure credential store is available on this platform; plaintext storage is never used.")
114
+ reasons = []
115
+ for name in order:
116
+ try:
117
+ return _try(name, target, state_dir, environ)
118
+ except StoreUnavailable as error:
119
+ reasons.append((name, str(error)))
120
+ if platform.startswith("linux"):
121
+ raise _guidance(target, reasons, state_dir)
122
+ raise AuthError("\n".join(["No secure credential store is available."] + [f"- {n}: {r}" for n, r in reasons]))