smorg 1.2.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.
smorg/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from importlib.metadata import version
2
+
3
+ # smorg only ever runs installed — uv installs the project before running
4
+ # anything — so an uninstalled import has nothing sensible to report.
5
+ __version__ = version("smorg")
smorg/auth/__init__.py ADDED
File without changes
smorg/auth/login.py ADDED
@@ -0,0 +1,139 @@
1
+ """The browser-driven half of OAuth: run a loopback callback server, send the
2
+ user to authorize, wait for the redirect, and exchange the code.
3
+
4
+ Print-free: the caller decides how to surface the authorize URL
5
+ (`on_authorize_url`) and whether the wait can be cancelled (`cancelled`).
6
+ `cli.run_login` is the printing frontend; the in-app connect modal is the other.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import http.server
12
+ import secrets
13
+ import threading
14
+ import time
15
+ import urllib.parse
16
+ import webbrowser
17
+ from collections.abc import Callable
18
+
19
+ import httpx
20
+
21
+ from smorg.auth import oauth
22
+ from smorg.auth.oauth import ProviderConfig
23
+ from smorg.auth.store import Credentials
24
+ from smorg.core.text import sanitize_line
25
+
26
+ LOGIN_TIMEOUT_SECONDS = 300
27
+
28
+
29
+ class LoginCancelled(oauth.OAuthError):
30
+ """The caller's `cancelled` event was set before the callback arrived."""
31
+
32
+
33
+ def _callback_handler(
34
+ expected_state: str, received: dict[str, str]
35
+ ) -> type[http.server.BaseHTTPRequestHandler]:
36
+ class Handler(http.server.BaseHTTPRequestHandler):
37
+ def do_GET(self) -> None:
38
+ parsed = urllib.parse.urlparse(self.path)
39
+ parsed_query = urllib.parse.parse_qs(parsed.query)
40
+ query = {key: value[0] for key, value in parsed_query.items()}
41
+ # Only a request whose `state` matches this login's is accepted —
42
+ # a stray probe or a forged ?error= is refused without ending the
43
+ # wait. Compared as bytes, since compare_digest's str form raises
44
+ # on non-ASCII input.
45
+ if (
46
+ parsed.path != "/callback"
47
+ or not secrets.compare_digest(
48
+ query.get("state", "").encode(), expected_state.encode()
49
+ )
50
+ or not query.keys() & {"code", "error"}
51
+ ):
52
+ self.send_response(404)
53
+ self.end_headers()
54
+ return
55
+ received.update(query)
56
+ self.send_response(200)
57
+ self.send_header("content-type", "text/plain; charset=utf-8")
58
+ self.end_headers()
59
+ self.wfile.write(b"smorg: authentication complete. You can close this tab.")
60
+
61
+ def log_message(self, format: str, *args: object) -> None:
62
+ """Silence the default access log, which would print over our output."""
63
+
64
+ return Handler
65
+
66
+
67
+ def _cancellation_requested(cancelled: threading.Event | None) -> bool:
68
+ if cancelled is None:
69
+ return False
70
+ return cancelled.is_set()
71
+
72
+
73
+ def perform_login(
74
+ client: httpx.Client,
75
+ provider: ProviderConfig,
76
+ client_id: str | None,
77
+ *,
78
+ on_authorize_url: Callable[[str], None],
79
+ cancelled: threading.Event | None = None,
80
+ port: int = 0,
81
+ timeout: float = LOGIN_TIMEOUT_SECONDS,
82
+ ) -> tuple[str, Credentials]:
83
+ """Register if needed, take the user through the browser, return the tokens.
84
+
85
+ Returns the client id alongside the credentials so a first-time
86
+ registration can be persisted for later logins to reuse. The callback
87
+ listens on an ephemeral port nothing else can predict or squat in
88
+ advance; see REGISTRATION_PORT for the fixed port named at registration.
89
+ """
90
+ verifier, challenge = oauth.make_pkce_pair()
91
+ state = secrets.token_urlsafe(16)
92
+ received: dict[str, str] = {}
93
+
94
+ try:
95
+ server = http.server.HTTPServer(("127.0.0.1", port), _callback_handler(state, received))
96
+ except OSError as error:
97
+ raise oauth.OAuthError(f"could not open a port for the callback: {error}") from error
98
+
99
+ try:
100
+ # Bound before the redirect is built so an ephemeral port resolves to
101
+ # the one actually listening.
102
+ redirect_uri = oauth.redirect_uri_for(server.server_port)
103
+ metadata = oauth.discover(client, provider)
104
+ if client_id is None:
105
+ client_id = oauth.register_client(
106
+ client, metadata, provider, oauth.REGISTERED_REDIRECT_URI
107
+ )
108
+
109
+ url = oauth.build_authorize_url(
110
+ metadata, client_id, redirect_uri, challenge, provider.scopes, state
111
+ )
112
+ on_authorize_url(url)
113
+ webbrowser.open(url)
114
+
115
+ # One deadline for the whole wait rather than per request, so a drip of
116
+ # stray requests cannot extend it indefinitely. cancelled is polled at
117
+ # the same 1-second granularity as the deadline.
118
+ server.timeout = 1.0 # seconds
119
+ deadline = time.monotonic() + timeout
120
+ while (
121
+ not received and time.monotonic() < deadline and not _cancellation_requested(cancelled)
122
+ ):
123
+ server.handle_request()
124
+
125
+ # A code that already arrived wins over a cancellation requested in
126
+ # the same instant — only an empty result can still be cancelled.
127
+ if not received:
128
+ if _cancellation_requested(cancelled):
129
+ raise LoginCancelled("login cancelled before the browser callback arrived")
130
+ raise oauth.OAuthError("timed out waiting for the browser callback")
131
+ if "error" in received:
132
+ raise oauth.OAuthError(f"authorization was refused: {sanitize_line(received['error'])}")
133
+
134
+ credentials = oauth.exchange_code(
135
+ client, metadata, client_id, received["code"], verifier, redirect_uri
136
+ )
137
+ return client_id, credentials
138
+ finally:
139
+ server.server_close()
smorg/auth/oauth.py ADDED
@@ -0,0 +1,339 @@
1
+ """OAuth 2.1: discovery, dynamic client registration, PKCE, refresh, revocation.
2
+
3
+ An integration supplies a ProviderConfig and gets Credentials back; nothing here
4
+ knows which service is on the other end. Registration asks for a public client,
5
+ so no client secret exists anywhere in this flow; PKCE is what binds the
6
+ authorization code to the process that requested it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import secrets
14
+ from dataclasses import dataclass
15
+ from datetime import timedelta
16
+ from typing import Any
17
+ from urllib.parse import urlencode, urlsplit
18
+
19
+ import httpx
20
+
21
+ from smorg.auth.store import Credentials, now
22
+
23
+ # A decoded JSON response body. Values stay Any because the wire format is the
24
+ # server's to choose; every field this module reads is validated where it is used.
25
+ JsonObject = dict[str, Any]
26
+
27
+ __all__ = [
28
+ "REGISTERED_REDIRECT_URI",
29
+ "REGISTRATION_PORT",
30
+ "OAuthError",
31
+ "ProviderConfig",
32
+ "ServerMetadata",
33
+ "build_authorize_url",
34
+ "discover",
35
+ "exchange_code",
36
+ "extra_scopes_warning",
37
+ "make_pkce_pair",
38
+ "refresh_credentials",
39
+ "register_client",
40
+ "revoke",
41
+ ]
42
+
43
+ # The port named at registration time, not the one the callback listens on.
44
+ # The callback binds an ephemeral port instead, so nothing can squat this one
45
+ # in advance; a provider that insists on an exact match needs its own opt-out.
46
+ REGISTRATION_PORT = 8765
47
+
48
+
49
+ def redirect_uri_for(port: int) -> str:
50
+ return f"http://127.0.0.1:{port}/callback"
51
+
52
+
53
+ REGISTERED_REDIRECT_URI = redirect_uri_for(REGISTRATION_PORT)
54
+
55
+
56
+ class OAuthError(Exception):
57
+ """A registration, token, or discovery request failed. Never carries a token."""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ProviderConfig:
62
+ metadata_url: str
63
+ scopes: tuple[str, ...]
64
+ client_name: str
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ServerMetadata:
69
+ authorization_endpoint: str
70
+ token_endpoint: str
71
+ registration_endpoint: str
72
+ revocation_endpoint: str | None = None
73
+ resource: str | None = None
74
+
75
+
76
+ def _json_object(response: httpx.Response, source: str) -> JsonObject:
77
+ """Decode a response body that must be a JSON object.
78
+
79
+ A 200 carrying something else is routine in the wild (a proxy or captive
80
+ portal answering with HTML), so it has to surface as OAuthError like every
81
+ other failure here, not as a decoder traceback.
82
+ """
83
+ try:
84
+ payload = response.json()
85
+ except ValueError as error:
86
+ raise OAuthError(f"{source} returned a body that is not JSON") from error
87
+ if not isinstance(payload, dict):
88
+ raise OAuthError(f"{source} returned {type(payload).__name__}, expected a JSON object")
89
+ return payload
90
+
91
+
92
+ def _require_https(url: str, name: str) -> str:
93
+ """Refuse a plaintext endpoint named by a metadata document.
94
+
95
+ Endpoints inside the (TLS-verified) metadata are still whatever the
96
+ provider wrote; an http token endpoint would otherwise POST a refresh
97
+ token in the clear. The loopback redirect is exempt; it never reaches
98
+ this check.
99
+ """
100
+ if urlsplit(url).scheme != "https":
101
+ raise OAuthError(f"the {name} endpoint is not https: {url}")
102
+ return url
103
+
104
+
105
+ def _require_https_if_present(url: str | None, name: str) -> str | None:
106
+ return None if url is None else _require_https(url, name)
107
+
108
+
109
+ def discover(client: httpx.Client, provider: ProviderConfig) -> ServerMetadata:
110
+ try:
111
+ response = client.get(provider.metadata_url)
112
+ except httpx.HTTPError as error:
113
+ raise OAuthError(f"could not reach {provider.metadata_url}") from error
114
+ if response.status_code != 200:
115
+ raise OAuthError(f"metadata discovery failed with {response.status_code}")
116
+ payload = _json_object(response, "the metadata endpoint")
117
+ try:
118
+ return ServerMetadata(
119
+ authorization_endpoint=_require_https(payload["authorization_endpoint"], "authorize"),
120
+ token_endpoint=_require_https(payload["token_endpoint"], "token"),
121
+ registration_endpoint=_require_https(payload["registration_endpoint"], "registration"),
122
+ revocation_endpoint=_require_https_if_present(
123
+ payload.get("revocation_endpoint"), "revocation"
124
+ ),
125
+ resource=payload.get("resource"),
126
+ )
127
+ except KeyError as error:
128
+ raise OAuthError(f"metadata document is missing {error}") from error
129
+
130
+
131
+ def register_client(
132
+ client: httpx.Client,
133
+ metadata: ServerMetadata,
134
+ provider: ProviderConfig,
135
+ redirect_uri: str,
136
+ ) -> str:
137
+ try:
138
+ response = client.post(
139
+ metadata.registration_endpoint,
140
+ json={
141
+ "client_name": provider.client_name,
142
+ "redirect_uris": [redirect_uri],
143
+ "grant_types": ["authorization_code", "refresh_token"],
144
+ "response_types": ["code"],
145
+ "token_endpoint_auth_method": "none",
146
+ },
147
+ )
148
+ except httpx.HTTPError as error:
149
+ raise OAuthError("could not reach the registration endpoint") from error
150
+ if response.status_code not in (200, 201):
151
+ raise OAuthError(f"client registration failed with {response.status_code}")
152
+ try:
153
+ return _json_object(response, "the registration endpoint")["client_id"]
154
+ except KeyError as error:
155
+ raise OAuthError("registration response contained no client_id") from error
156
+
157
+
158
+ def make_pkce_pair() -> tuple[str, str]:
159
+ verifier = secrets.token_urlsafe(64)
160
+ digest = hashlib.sha256(verifier.encode()).digest()
161
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
162
+ return verifier, challenge
163
+
164
+
165
+ def build_authorize_url(
166
+ metadata: ServerMetadata,
167
+ client_id: str,
168
+ redirect_uri: str,
169
+ challenge: str,
170
+ scopes: tuple[str, ...],
171
+ state: str,
172
+ ) -> str:
173
+ params = {
174
+ "response_type": "code",
175
+ "client_id": client_id,
176
+ "redirect_uri": redirect_uri,
177
+ "scope": " ".join(scopes),
178
+ "state": state,
179
+ "code_challenge": challenge,
180
+ "code_challenge_method": "S256",
181
+ }
182
+ if metadata.resource:
183
+ params["resource"] = metadata.resource
184
+ return f"{metadata.authorization_endpoint}?{urlencode(params)}"
185
+
186
+
187
+ def _credentials_from_token_response(
188
+ payload: JsonObject, fallback_refresh: str | None
189
+ ) -> Credentials:
190
+ expires_in = payload.get("expires_in")
191
+ # RFC 6749 says a number; some providers send it as a decimal string. Accept
192
+ # both, but say which field is wrong rather than blaming the access token.
193
+ if isinstance(expires_in, str):
194
+ try:
195
+ expires_in = int(expires_in)
196
+ except ValueError as error:
197
+ raise OAuthError(
198
+ f"token response gave a non-numeric expires_in: {expires_in!r}"
199
+ ) from error
200
+ received_refresh_token = payload.get("refresh_token")
201
+ if received_refresh_token:
202
+ refresh_token = received_refresh_token
203
+ else:
204
+ # Omission means the refresh token we already hold stays valid;
205
+ # dropping it here would silently break the refresh after next.
206
+ refresh_token = fallback_refresh
207
+
208
+ if expires_in is None:
209
+ # expires_in of 0 means the token is already dead; None means the
210
+ # server said nothing about expiry at all.
211
+ expires_at = None
212
+ else:
213
+ expires_at = now() + timedelta(seconds=expires_in)
214
+
215
+ try:
216
+ return Credentials(
217
+ access_token=payload["access_token"],
218
+ refresh_token=refresh_token,
219
+ expires_at=expires_at,
220
+ scope=payload.get("scope", ""),
221
+ )
222
+ except (KeyError, TypeError) as error:
223
+ raise OAuthError("token response contained no usable access_token") from error
224
+
225
+
226
+ def _post_token(client: httpx.Client, metadata: ServerMetadata, form: dict[str, str]) -> JsonObject:
227
+ # Binds the issued token to the protected resource; omit it and the token
228
+ # carries the wrong audience — rejected later at the API, not here.
229
+ if metadata.resource:
230
+ form = form | {"resource": metadata.resource}
231
+ try:
232
+ response = client.post(metadata.token_endpoint, data=form)
233
+ except httpx.HTTPError as error:
234
+ raise OAuthError("could not reach the token endpoint") from error
235
+ if response.status_code != 200:
236
+ try:
237
+ reason = response.json().get("error", "unknown_error")
238
+ except (ValueError, AttributeError):
239
+ reason = "unparseable error response"
240
+ raise OAuthError(f"token request failed with {response.status_code}: {reason}")
241
+ return _json_object(response, "the token endpoint")
242
+
243
+
244
+ def exchange_code(
245
+ client: httpx.Client,
246
+ metadata: ServerMetadata,
247
+ client_id: str,
248
+ code: str,
249
+ verifier: str,
250
+ redirect_uri: str,
251
+ ) -> Credentials:
252
+ payload = _post_token(
253
+ client,
254
+ metadata,
255
+ {
256
+ "grant_type": "authorization_code",
257
+ "code": code,
258
+ "redirect_uri": redirect_uri,
259
+ "client_id": client_id,
260
+ "code_verifier": verifier,
261
+ },
262
+ )
263
+ return _credentials_from_token_response(payload, fallback_refresh=None)
264
+
265
+
266
+ def refresh_credentials(
267
+ client: httpx.Client,
268
+ metadata: ServerMetadata,
269
+ client_id: str,
270
+ credentials: Credentials,
271
+ ) -> Credentials:
272
+ if credentials.refresh_token is None:
273
+ raise OAuthError("no refresh token available; re-run smorg connect")
274
+ payload = _post_token(
275
+ client,
276
+ metadata,
277
+ {
278
+ "grant_type": "refresh_token",
279
+ "refresh_token": credentials.refresh_token,
280
+ "client_id": client_id,
281
+ },
282
+ )
283
+ return _credentials_from_token_response(payload, fallback_refresh=credentials.refresh_token)
284
+
285
+
286
+ def extra_scopes_warning(
287
+ integration_id: str, display_name: str, provider: ProviderConfig, credentials: Credentials
288
+ ) -> str | None:
289
+ """None when the provider granted nothing beyond what was requested.
290
+
291
+ A warning, not a refusal: every call site is read-only, so an
292
+ over-scoped token's only cost is being a bigger prize if stolen. Shared
293
+ text so a CLI print and a TUI toast never drift apart.
294
+ """
295
+ granted = set(credentials.scope.split())
296
+ requested = set(provider.scopes)
297
+ extra = sorted(granted - requested)
298
+ if not extra:
299
+ return None
300
+ return (
301
+ f"{display_name} granted scopes smorg did not ask for: {', '.join(extra)}. "
302
+ f"Nothing here uses them, but the stored token can. "
303
+ f"Run 'smorg logout {integration_id}' to revoke it."
304
+ )
305
+
306
+
307
+ def revoke(
308
+ client: httpx.Client,
309
+ metadata: ServerMetadata,
310
+ client_id: str,
311
+ credentials: Credentials,
312
+ ) -> bool:
313
+ """Ask the server to invalidate the refresh token (RFC 7009).
314
+
315
+ Reports success rather than raising: the caller deletes the local copy
316
+ either way, so a server that is unreachable or has already forgotten the
317
+ token must not leave credentials stranded on the machine.
318
+ """
319
+ if metadata.revocation_endpoint is None:
320
+ return False
321
+ if credentials.refresh_token:
322
+ token = credentials.refresh_token
323
+ else:
324
+ # Revoking the refresh token is what matters — it outlives the
325
+ # session; with none, the access token is the only thing left worth
326
+ # invalidating.
327
+ token = credentials.access_token
328
+ try:
329
+ response = client.post(
330
+ metadata.revocation_endpoint,
331
+ data={
332
+ "token": token,
333
+ "token_type_hint": "refresh_token" if credentials.refresh_token else "access_token",
334
+ "client_id": client_id,
335
+ },
336
+ )
337
+ except httpx.HTTPError:
338
+ return False
339
+ return response.status_code == 200
smorg/auth/refresh.py ADDED
@@ -0,0 +1,85 @@
1
+ """The refresh decision: hand fetching a token that is not about to expire.
2
+
3
+ Called from fetch worker threads. The lock is what makes two tabs' concurrent
4
+ refreshes safe: only one thread talks to the token endpoint; the others block,
5
+ re-read the store, and find fresh credentials already there.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from datetime import timedelta
12
+
13
+ import httpx
14
+
15
+ from smorg.auth import oauth
16
+ from smorg.auth.oauth import OAuthError, ProviderConfig
17
+ from smorg.auth.store import Credentials, get_credentials, now, set_credentials
18
+ from smorg.auth.token import TokenPrompt
19
+ from smorg.core.contract import AuthExpired, ConnectionPath
20
+
21
+ # How close to expiry counts as expired: covers clock skew against the
22
+ # provider plus the gap between this check and the request using the token.
23
+ EXPIRY_MARGIN = timedelta(seconds=120)
24
+
25
+ _locks: dict[str, threading.Lock] = {}
26
+ _locks_guard = threading.Lock()
27
+
28
+
29
+ def _lock_for(integration_id: str) -> threading.Lock:
30
+ with _locks_guard:
31
+ return _locks.setdefault(integration_id, threading.Lock())
32
+
33
+
34
+ def _expiring(credentials: Credentials) -> bool:
35
+ if credentials.expires_at is None:
36
+ return False
37
+ return now() >= credentials.expires_at - EXPIRY_MARGIN
38
+
39
+
40
+ def credentials_for(
41
+ integration_id: str,
42
+ path: ConnectionPath,
43
+ client_id: str | None,
44
+ http: httpx.Client,
45
+ ) -> Credentials | None:
46
+ """The credentials a fetch should use, renewed first where that is possible."""
47
+ method = path.method
48
+ if isinstance(method, TokenPrompt):
49
+ return get_credentials(integration_id)
50
+
51
+ return fresh_credentials(integration_id, method, client_id, http)
52
+
53
+
54
+ def fresh_credentials(
55
+ integration_id: str,
56
+ provider: ProviderConfig,
57
+ client_id: str | None,
58
+ http: httpx.Client,
59
+ ) -> Credentials | None:
60
+ """Stored credentials, refreshed and re-persisted when about to expire.
61
+
62
+ None means not connected. Credentials that cannot be refreshed (no refresh
63
+ token, no client id) are returned as-is: the server rejecting them produces
64
+ the same AuthExpired the shell already handles.
65
+ """
66
+ credentials = get_credentials(integration_id)
67
+ if credentials is None or not _expiring(credentials):
68
+ return credentials
69
+ if credentials.refresh_token is None or client_id is None:
70
+ return credentials
71
+ lock = _lock_for(integration_id)
72
+ with lock:
73
+ credentials = get_credentials(integration_id)
74
+ if credentials is None or not _expiring(credentials):
75
+ return credentials
76
+ try:
77
+ metadata = oauth.discover(http, provider)
78
+ refreshed = oauth.refresh_credentials(http, metadata, client_id, credentials)
79
+ except OAuthError as error:
80
+ raise AuthExpired(f"token refresh failed ({error})") from error
81
+ # A store failure here propagates as CredentialStoreError on purpose:
82
+ # Linear rotates refresh tokens, so silently dropping the new one
83
+ # would break every refresh after this session.
84
+ set_credentials(integration_id, refreshed)
85
+ return refreshed