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 +5 -0
- smorg/auth/__init__.py +0 -0
- smorg/auth/login.py +139 -0
- smorg/auth/oauth.py +339 -0
- smorg/auth/refresh.py +85 -0
- smorg/auth/store.py +260 -0
- smorg/auth/token.py +59 -0
- smorg/cli.py +293 -0
- smorg/core/__init__.py +1 -0
- smorg/core/config.py +179 -0
- smorg/core/contract.py +154 -0
- smorg/core/keys.py +46 -0
- smorg/core/mcp.py +221 -0
- smorg/core/path_setup.py +75 -0
- smorg/core/registry.py +50 -0
- smorg/core/removal.py +92 -0
- smorg/core/shape.py +37 -0
- smorg/core/state.py +83 -0
- smorg/core/text.py +37 -0
- smorg/core/update.py +48 -0
- smorg/integrations/__init__.py +13 -0
- smorg/integrations/github/__init__.py +3 -0
- smorg/integrations/github/manifest.py +54 -0
- smorg/integrations/github/panel.py +277 -0
- smorg/integrations/github/source.py +319 -0
- smorg/integrations/linear/__init__.py +3 -0
- smorg/integrations/linear/manifest.py +43 -0
- smorg/integrations/linear/panel.py +245 -0
- smorg/integrations/linear/source.py +198 -0
- smorg/shell/__init__.py +0 -0
- smorg/shell/app.py +572 -0
- smorg/shell/format.py +21 -0
- smorg/shell/help.py +84 -0
- smorg/shell/markdown.py +64 -0
- smorg/shell/menu.py +593 -0
- smorg/shell/modal.py +44 -0
- smorg/shell/panel.py +298 -0
- smorg/shell/refresh_indicator.py +84 -0
- smorg/shell/terminal_palette.py +213 -0
- smorg-1.2.0.dist-info/METADATA +61 -0
- smorg-1.2.0.dist-info/RECORD +43 -0
- smorg-1.2.0.dist-info/WHEEL +4 -0
- smorg-1.2.0.dist-info/entry_points.txt +3 -0
smorg/__init__.py
ADDED
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
|