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/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]))
|