onehouse-python-sdk 0.2.0__py3-none-any.whl → 0.3.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.
- onehouse_python_sdk/_oauth.py +293 -0
- onehouse_python_sdk/_session.py +169 -0
- onehouse_python_sdk/ai_gateway/__init__.py +29 -0
- onehouse_python_sdk/ai_gateway/discovery.py +143 -0
- onehouse_python_sdk/ai_gateway/exceptions.py +11 -0
- onehouse_python_sdk/ai_gateway/tokens.py +223 -0
- onehouse_python_sdk/resources/client.py +6 -1
- onehouse_python_sdk/resources/credentials.py +41 -12
- onehouse_python_sdk/resources/oauth.py +258 -0
- onehouse_python_sdk/resources/transport.py +31 -3
- {onehouse_python_sdk-0.2.0.dist-info → onehouse_python_sdk-0.3.0.dist-info}/METADATA +135 -3
- {onehouse_python_sdk-0.2.0.dist-info → onehouse_python_sdk-0.3.0.dist-info}/RECORD +13 -6
- {onehouse_python_sdk-0.2.0.dist-info → onehouse_python_sdk-0.3.0.dist-info}/WHEEL +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Loopback PKCE sign-in and token-endpoint calls, shared by every OAuth login.
|
|
2
|
+
|
|
3
|
+
Standard library only, and outside every subsystem, so both the control-plane
|
|
4
|
+
login (``resources.oauth``, external-api's OAuth server) and the data-plane
|
|
5
|
+
AI-Gateway login (``ai_gateway``, Keycloak) use it without importing each other.
|
|
6
|
+
|
|
7
|
+
The browser only ever carries an authorization code, which is worthless
|
|
8
|
+
without the PKCE verifier that never leaves this process (RFC 7636, RFC 8252).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import http.server
|
|
16
|
+
import json
|
|
17
|
+
import secrets
|
|
18
|
+
import ssl
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
import webbrowser
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Callable, Mapping, Optional
|
|
26
|
+
|
|
27
|
+
from .exceptions import OnehouseSdkError
|
|
28
|
+
|
|
29
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
30
|
+
_HTTP_TIMEOUT_SECONDS = 20
|
|
31
|
+
|
|
32
|
+
_DONE_PAGE = b"""<!doctype html><meta charset="utf-8"><title>Onehouse</title>
|
|
33
|
+
<body style="font:16px system-ui;padding:3rem;max-width:32rem">
|
|
34
|
+
<h2>Signed in</h2><p>You can close this tab and return to your terminal.</p></body>"""
|
|
35
|
+
|
|
36
|
+
_FAILED_PAGE = b"""<!doctype html><meta charset="utf-8"><title>Onehouse</title>
|
|
37
|
+
<body style="font:16px system-ui;padding:3rem;max-width:32rem">
|
|
38
|
+
<h2>Sign-in did not complete</h2><p>Return to your terminal for the reason.</p></body>"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OAuthFlowError(OnehouseSdkError):
|
|
42
|
+
"""A sign-in or token-endpoint call did not produce a usable token."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AuthorizationResult:
|
|
47
|
+
"""What the browser step returns: redeem ``code`` with ``verifier`` at the same
|
|
48
|
+
``redirect_uri``, byte for byte."""
|
|
49
|
+
|
|
50
|
+
code: str
|
|
51
|
+
redirect_uri: str
|
|
52
|
+
verifier: str
|
|
53
|
+
iss: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _b64url(raw: bytes) -> str:
|
|
57
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def new_verifier() -> str:
|
|
61
|
+
"""43 characters of base64url (RFC 7636 4.1)."""
|
|
62
|
+
return _b64url(secrets.token_bytes(32))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def challenge_for(verifier: str) -> str:
|
|
66
|
+
"""S256 only: ``plain`` would put the verifier in the browser's history."""
|
|
67
|
+
return _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_ssl_context() -> ssl.SSLContext:
|
|
71
|
+
"""The OS trust store when ``truststore`` is installed, so a TLS-intercepting
|
|
72
|
+
corporate proxy's CA is trusted; then certifi; then Python's defaults.
|
|
73
|
+
The same order as ``lakebase.auth._ssl``, which this module may not import."""
|
|
74
|
+
try:
|
|
75
|
+
import truststore
|
|
76
|
+
|
|
77
|
+
return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
78
|
+
except ImportError:
|
|
79
|
+
pass
|
|
80
|
+
try:
|
|
81
|
+
import certifi
|
|
82
|
+
|
|
83
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
84
|
+
except ImportError:
|
|
85
|
+
return ssl.create_default_context()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Catcher(http.server.BaseHTTPRequestHandler):
|
|
89
|
+
"""Serves one useful request: the authorization server's redirect back."""
|
|
90
|
+
|
|
91
|
+
callback_path = ""
|
|
92
|
+
expected_state = ""
|
|
93
|
+
result: dict = {}
|
|
94
|
+
# A connection that sends nothing (a browser preconnect, a port scan) would
|
|
95
|
+
# otherwise block handle_request() past the login deadline.
|
|
96
|
+
timeout = 5
|
|
97
|
+
|
|
98
|
+
def do_GET(self) -> None: # noqa: N802 - name fixed by BaseHTTPRequestHandler
|
|
99
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
100
|
+
if parsed.path != type(self).callback_path:
|
|
101
|
+
self.send_response(404)
|
|
102
|
+
self.end_headers()
|
|
103
|
+
return
|
|
104
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
105
|
+
# State before anything else: a callback this process did not start is not
|
|
106
|
+
# ours to act on, and must not end the wait.
|
|
107
|
+
got_state = (params.get("state") or [""])[0]
|
|
108
|
+
if not secrets.compare_digest(got_state, type(self).expected_state):
|
|
109
|
+
self._respond(400, _FAILED_PAGE)
|
|
110
|
+
return
|
|
111
|
+
error = (params.get("error") or [""])[0]
|
|
112
|
+
if error:
|
|
113
|
+
description = (params.get("error_description") or [""])[0]
|
|
114
|
+
type(self).result = {"error": f"{error}: {description}" if description else error}
|
|
115
|
+
self._respond(400, _FAILED_PAGE)
|
|
116
|
+
return
|
|
117
|
+
code = (params.get("code") or [""])[0]
|
|
118
|
+
if not code:
|
|
119
|
+
type(self).result = {"error": "the authorization server returned no code"}
|
|
120
|
+
self._respond(400, _FAILED_PAGE)
|
|
121
|
+
return
|
|
122
|
+
type(self).result = {"code": code, "iss": (params.get("iss") or [None])[0]}
|
|
123
|
+
self._respond(200, _DONE_PAGE)
|
|
124
|
+
|
|
125
|
+
def _respond(self, status: int, body: bytes) -> None:
|
|
126
|
+
self.send_response(status)
|
|
127
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
128
|
+
self.send_header("Content-Length", str(len(body)))
|
|
129
|
+
self.end_headers()
|
|
130
|
+
self.wfile.write(body)
|
|
131
|
+
|
|
132
|
+
def log_message(self, *_args) -> None:
|
|
133
|
+
"""The default access log would print the callback URL, code included."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class LoopbackServer:
|
|
137
|
+
"""A one-shot HTTP server on 127.0.0.1, on an OS-chosen port unless one is given.
|
|
138
|
+
|
|
139
|
+
127.0.0.1, never ``localhost``: redirect matching is byte for byte, and
|
|
140
|
+
``localhost`` resolves through DNS (RFC 8252 7.3, 8.3).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, callback_path: str, state: str, port: int = 0) -> None:
|
|
144
|
+
self._callback_path = callback_path
|
|
145
|
+
self._handler = type(
|
|
146
|
+
"_Bound",
|
|
147
|
+
(_Catcher,),
|
|
148
|
+
{"callback_path": callback_path, "expected_state": state, "result": {}},
|
|
149
|
+
)
|
|
150
|
+
self._httpd = http.server.HTTPServer(("127.0.0.1", port), self._handler)
|
|
151
|
+
self._httpd.timeout = 0.5
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def port(self) -> int:
|
|
155
|
+
return self._httpd.server_address[1]
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def redirect_uri(self) -> str:
|
|
159
|
+
return f"http://127.0.0.1:{self.port}{self._callback_path}"
|
|
160
|
+
|
|
161
|
+
def wait(self, timeout: float) -> dict:
|
|
162
|
+
deadline = time.monotonic() + timeout
|
|
163
|
+
while time.monotonic() < deadline:
|
|
164
|
+
try:
|
|
165
|
+
self._httpd.handle_request()
|
|
166
|
+
except OSError:
|
|
167
|
+
# A connection that timed out or reset mid-read is not an outcome.
|
|
168
|
+
continue
|
|
169
|
+
if self._handler.result:
|
|
170
|
+
return self._handler.result
|
|
171
|
+
return {"error": f"timed out after {int(timeout)}s waiting for the browser"}
|
|
172
|
+
|
|
173
|
+
def close(self) -> None:
|
|
174
|
+
self._httpd.server_close()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def authorize(
|
|
178
|
+
authorization_endpoint: str,
|
|
179
|
+
*,
|
|
180
|
+
client_id: str,
|
|
181
|
+
callback_path: str,
|
|
182
|
+
scope: Optional[str] = None,
|
|
183
|
+
extra_params: Optional[Mapping[str, str]] = None,
|
|
184
|
+
open_browser: Callable[[str], object] = webbrowser.open,
|
|
185
|
+
announce: Callable[[str], None] = print,
|
|
186
|
+
timeout: float = LOGIN_TIMEOUT_SECONDS,
|
|
187
|
+
port: int = 0,
|
|
188
|
+
) -> AuthorizationResult:
|
|
189
|
+
"""Run the browser step of an authorization-code + PKCE sign-in.
|
|
190
|
+
|
|
191
|
+
``port`` pins the loopback port, which only an SSH forward needs: the tunnel
|
|
192
|
+
must name a port before the sign-in starts.
|
|
193
|
+
"""
|
|
194
|
+
verifier = new_verifier()
|
|
195
|
+
state = _b64url(secrets.token_bytes(16))
|
|
196
|
+
try:
|
|
197
|
+
server = LoopbackServer(callback_path, state, port)
|
|
198
|
+
except OSError as err:
|
|
199
|
+
where = f"port {port}" if port else "a local port"
|
|
200
|
+
raise OAuthFlowError(f"could not bind {where} for the sign-in ({err})") from err
|
|
201
|
+
try:
|
|
202
|
+
params = {
|
|
203
|
+
"response_type": "code",
|
|
204
|
+
"client_id": client_id,
|
|
205
|
+
"redirect_uri": server.redirect_uri,
|
|
206
|
+
"state": state,
|
|
207
|
+
"code_challenge": challenge_for(verifier),
|
|
208
|
+
"code_challenge_method": "S256",
|
|
209
|
+
}
|
|
210
|
+
if scope:
|
|
211
|
+
params["scope"] = scope
|
|
212
|
+
params.update(extra_params or {})
|
|
213
|
+
separator = "&" if "?" in authorization_endpoint else "?"
|
|
214
|
+
url = f"{authorization_endpoint}{separator}{urllib.parse.urlencode(params)}"
|
|
215
|
+
announce(f"Opening your browser to sign in.\nIf it does not open, visit:\n {url}")
|
|
216
|
+
open_browser(url)
|
|
217
|
+
outcome = server.wait(timeout)
|
|
218
|
+
redirect_uri = server.redirect_uri
|
|
219
|
+
finally:
|
|
220
|
+
server.close()
|
|
221
|
+
if "error" in outcome:
|
|
222
|
+
raise OAuthFlowError(outcome["error"])
|
|
223
|
+
return AuthorizationResult(
|
|
224
|
+
code=outcome["code"], redirect_uri=redirect_uri, verifier=verifier, iss=outcome.get("iss")
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True)
|
|
229
|
+
class HttpResult:
|
|
230
|
+
status: int
|
|
231
|
+
body: dict
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _read_json(raw: bytes) -> dict:
|
|
235
|
+
try:
|
|
236
|
+
parsed = json.loads(raw.decode("utf-8")) if raw else {}
|
|
237
|
+
except (ValueError, UnicodeDecodeError):
|
|
238
|
+
return {}
|
|
239
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _send(request: urllib.request.Request, timeout: float) -> HttpResult:
|
|
243
|
+
try:
|
|
244
|
+
with urllib.request.urlopen(
|
|
245
|
+
request, timeout=timeout, context=default_ssl_context()
|
|
246
|
+
) as resp:
|
|
247
|
+
return HttpResult(resp.status, _read_json(resp.read()))
|
|
248
|
+
except urllib.error.HTTPError as err:
|
|
249
|
+
# An OAuth error response is a 4xx with a JSON body worth reading.
|
|
250
|
+
return HttpResult(err.code, _read_json(err.read()))
|
|
251
|
+
except (urllib.error.URLError, OSError) as err:
|
|
252
|
+
raise OAuthFlowError(f"could not reach {request.full_url} ({err})") from err
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def post_form(url: str, data: Mapping[str, str], timeout: float = _HTTP_TIMEOUT_SECONDS) -> HttpResult:
|
|
256
|
+
"""POST ``application/x-www-form-urlencoded``, as every token endpoint takes it."""
|
|
257
|
+
request = urllib.request.Request(
|
|
258
|
+
url,
|
|
259
|
+
data=urllib.parse.urlencode(dict(data)).encode("ascii"),
|
|
260
|
+
headers={
|
|
261
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
262
|
+
"Accept": "application/json",
|
|
263
|
+
},
|
|
264
|
+
method="POST",
|
|
265
|
+
)
|
|
266
|
+
return _send(request, timeout)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_json(url: str, timeout: float = _HTTP_TIMEOUT_SECONDS) -> HttpResult:
|
|
270
|
+
request = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET")
|
|
271
|
+
return _send(request, timeout)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def oauth_error(result: HttpResult) -> str:
|
|
275
|
+
"""The RFC 6749 5.2 ``error`` code of a failed call, or its HTTP status."""
|
|
276
|
+
error = result.body.get("error")
|
|
277
|
+
return str(error) if error else f"HTTP {result.status}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
__all__ = [
|
|
281
|
+
"LOGIN_TIMEOUT_SECONDS",
|
|
282
|
+
"AuthorizationResult",
|
|
283
|
+
"HttpResult",
|
|
284
|
+
"LoopbackServer",
|
|
285
|
+
"OAuthFlowError",
|
|
286
|
+
"authorize",
|
|
287
|
+
"challenge_for",
|
|
288
|
+
"default_ssl_context",
|
|
289
|
+
"get_json",
|
|
290
|
+
"new_verifier",
|
|
291
|
+
"oauth_error",
|
|
292
|
+
"post_form",
|
|
293
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Where a signed-in session outlives the process, and the lock that guards refresh.
|
|
2
|
+
|
|
3
|
+
Standard library only, and outside every subsystem, so the control-plane login
|
|
4
|
+
and the AI-Gateway login share one store type. The CLI supplies a keychain store
|
|
5
|
+
with the same interface; this module ships the ``0600`` file store it falls back
|
|
6
|
+
to, so the SDK core never depends on ``keyring``.
|
|
7
|
+
|
|
8
|
+
Refresh tokens rotate. Presenting a rotated one again reads as theft and revokes
|
|
9
|
+
the whole chain, so two processes must never refresh from the same stored token.
|
|
10
|
+
Callers take :meth:`SessionStore.lock`, re-read, refresh only if still needed,
|
|
11
|
+
and write the new token before releasing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import asdict, dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterator, Optional, Protocol
|
|
25
|
+
|
|
26
|
+
try: # pragma: no cover - the platform decides which one exists
|
|
27
|
+
import fcntl
|
|
28
|
+
except ImportError: # pragma: no cover
|
|
29
|
+
fcntl = None # type: ignore[assignment]
|
|
30
|
+
try: # pragma: no cover
|
|
31
|
+
import msvcrt
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
msvcrt = None # type: ignore[assignment]
|
|
34
|
+
|
|
35
|
+
_LOCK_TIMEOUT_SECONDS = 30.0
|
|
36
|
+
_LOCK_POLL_SECONDS = 0.05
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Session:
|
|
41
|
+
"""``expires_at`` is the access token's expiry in epoch seconds."""
|
|
42
|
+
|
|
43
|
+
refresh_token: str
|
|
44
|
+
access_token: Optional[str] = None
|
|
45
|
+
expires_at: float = 0.0
|
|
46
|
+
|
|
47
|
+
def access_token_valid(self, skew_seconds: float, now: Optional[float] = None) -> bool:
|
|
48
|
+
current = time.time() if now is None else now
|
|
49
|
+
return bool(self.access_token) and self.expires_at - skew_seconds > current
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SessionStore(Protocol):
|
|
53
|
+
def get(self) -> Optional[Session]: ...
|
|
54
|
+
|
|
55
|
+
def put(self, session: Session) -> None: ...
|
|
56
|
+
|
|
57
|
+
def delete(self) -> None: ...
|
|
58
|
+
|
|
59
|
+
def lock(self) -> contextlib.AbstractContextManager: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FileLock:
|
|
63
|
+
"""An exclusive advisory lock on ``path``, held across processes.
|
|
64
|
+
|
|
65
|
+
``fcntl.flock`` on POSIX, ``msvcrt.locking`` on Windows. Blocks up to
|
|
66
|
+
``timeout`` seconds, then raises ``TimeoutError`` rather than hang a command.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, path: Path, timeout: float = _LOCK_TIMEOUT_SECONDS) -> None:
|
|
70
|
+
self._path = path
|
|
71
|
+
self._timeout = timeout
|
|
72
|
+
self._fd: Optional[int] = None
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> "FileLock":
|
|
75
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
76
|
+
fd = os.open(str(self._path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
77
|
+
deadline = time.monotonic() + self._timeout
|
|
78
|
+
while True:
|
|
79
|
+
try:
|
|
80
|
+
_try_lock(fd)
|
|
81
|
+
break
|
|
82
|
+
except OSError:
|
|
83
|
+
if time.monotonic() >= deadline:
|
|
84
|
+
os.close(fd)
|
|
85
|
+
raise TimeoutError(f"timed out waiting for the lock on {self._path}")
|
|
86
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
87
|
+
self._fd = fd
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, *_exc) -> None:
|
|
91
|
+
if self._fd is not None:
|
|
92
|
+
try:
|
|
93
|
+
_unlock(self._fd)
|
|
94
|
+
finally:
|
|
95
|
+
os.close(self._fd)
|
|
96
|
+
self._fd = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _try_lock(fd: int) -> None:
|
|
100
|
+
if fcntl is not None:
|
|
101
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
102
|
+
elif msvcrt is not None: # pragma: no cover - Windows
|
|
103
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
104
|
+
else: # pragma: no cover
|
|
105
|
+
raise OSError("no file locking available on this platform")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _unlock(fd: int) -> None:
|
|
109
|
+
if fcntl is not None:
|
|
110
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
111
|
+
elif msvcrt is not None: # pragma: no cover - Windows
|
|
112
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_private_file(path: Path, text: str) -> None:
|
|
116
|
+
"""Write ``text`` so no other user can read it, replacing ``path`` atomically.
|
|
117
|
+
|
|
118
|
+
On Windows ``chmod`` cannot express ``0600``; the file then relies on the
|
|
119
|
+
user-profile ACL, as ``~/.onehouse/credentials`` already does.
|
|
120
|
+
"""
|
|
121
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
122
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
|
|
123
|
+
try:
|
|
124
|
+
if sys.platform != "win32":
|
|
125
|
+
os.fchmod(fd, 0o600)
|
|
126
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
127
|
+
handle.write(text)
|
|
128
|
+
os.replace(tmp, str(path))
|
|
129
|
+
except BaseException:
|
|
130
|
+
with contextlib.suppress(OSError):
|
|
131
|
+
os.unlink(tmp)
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class FileSessionStore:
|
|
136
|
+
"""A session in one ``0600`` JSON file, locked through a sibling ``.lock`` file."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, path: Path) -> None:
|
|
139
|
+
self.path = Path(path).expanduser()
|
|
140
|
+
|
|
141
|
+
def get(self) -> Optional[Session]:
|
|
142
|
+
try:
|
|
143
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
return None
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
return None
|
|
148
|
+
if not isinstance(payload, dict) or not payload.get("refresh_token"):
|
|
149
|
+
return None
|
|
150
|
+
return Session(
|
|
151
|
+
refresh_token=str(payload["refresh_token"]),
|
|
152
|
+
access_token=payload.get("access_token") or None,
|
|
153
|
+
expires_at=float(payload.get("expires_at") or 0.0),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def put(self, session: Session) -> None:
|
|
157
|
+
write_private_file(self.path, json.dumps(asdict(session)))
|
|
158
|
+
|
|
159
|
+
def delete(self) -> None:
|
|
160
|
+
with contextlib.suppress(FileNotFoundError):
|
|
161
|
+
self.path.unlink()
|
|
162
|
+
|
|
163
|
+
@contextlib.contextmanager
|
|
164
|
+
def lock(self) -> Iterator[None]:
|
|
165
|
+
with FileLock(self.path.with_name(self.path.name + ".lock")):
|
|
166
|
+
yield
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = ["FileLock", "FileSessionStore", "Session", "SessionStore", "write_private_file"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""AI Gateway tokens: discovery, service-principal minting and the human Keycloak session.
|
|
2
|
+
|
|
3
|
+
A data-plane subsystem. Standard library only, and ``resources`` must not import
|
|
4
|
+
it, so ``oh login`` never pulls in gateway code. The gateway runs in the customer's
|
|
5
|
+
data plane behind its internal load balancer, so everything here needs VPN or the
|
|
6
|
+
customer network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .discovery import GatewayAuthMetadata, discover
|
|
10
|
+
from .exceptions import AiGatewayError, AuthDisabledError
|
|
11
|
+
from .tokens import (
|
|
12
|
+
KEYCLOAK_CLIENT_ID,
|
|
13
|
+
KeycloakSessionTokenSource,
|
|
14
|
+
ServicePrincipalTokenSource,
|
|
15
|
+
login,
|
|
16
|
+
logout,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"KEYCLOAK_CLIENT_ID",
|
|
21
|
+
"AiGatewayError",
|
|
22
|
+
"AuthDisabledError",
|
|
23
|
+
"GatewayAuthMetadata",
|
|
24
|
+
"KeycloakSessionTokenSource",
|
|
25
|
+
"ServicePrincipalTokenSource",
|
|
26
|
+
"discover",
|
|
27
|
+
"login",
|
|
28
|
+
"logout",
|
|
29
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Find the gateway's authorization server from the gateway itself.
|
|
2
|
+
|
|
3
|
+
The Keycloak token URL is discovered, never derived from the gateway host or the
|
|
4
|
+
link uid, and never configured: the same chain an MCP client follows.
|
|
5
|
+
|
|
6
|
+
1. RFC 9728 protected-resource metadata from the gateway, path form first:
|
|
7
|
+
``resource`` is the ``aud`` the edge requires, ``authorization_servers[0]``
|
|
8
|
+
the issuer. Images from before ENG-50210 serve only the bare path.
|
|
9
|
+
2. RFC 8414 metadata for that issuer, inserted form first, then OIDC discovery.
|
|
10
|
+
The returned ``issuer`` must equal the one asked for (RFC 8414 3.3).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Dict, Optional
|
|
19
|
+
|
|
20
|
+
from .. import _oauth
|
|
21
|
+
from .exceptions import AiGatewayError, AuthDisabledError
|
|
22
|
+
|
|
23
|
+
_PROTECTED_RESOURCE_PATHS = (
|
|
24
|
+
"/.well-known/oauth-protected-resource/mcp",
|
|
25
|
+
"/.well-known/oauth-protected-resource",
|
|
26
|
+
)
|
|
27
|
+
_VPN_HINT = "AI Gateway hosts resolve only on the VPN or the customer network"
|
|
28
|
+
|
|
29
|
+
_cache: Dict[str, "GatewayAuthMetadata"] = {}
|
|
30
|
+
_cache_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class GatewayAuthMetadata:
|
|
35
|
+
gateway_url: str
|
|
36
|
+
resource: str
|
|
37
|
+
issuer: str
|
|
38
|
+
token_endpoint: str
|
|
39
|
+
authorization_endpoint: Optional[str]
|
|
40
|
+
end_session_endpoint: Optional[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_gateway_url(gateway_url: str) -> str:
|
|
44
|
+
parts = urllib.parse.urlsplit(gateway_url.strip())
|
|
45
|
+
if parts.scheme != "https" or not parts.netloc:
|
|
46
|
+
raise AiGatewayError(
|
|
47
|
+
f"AI Gateway URL {gateway_url!r} must be an https URL, such as "
|
|
48
|
+
"https://ai-gateway-<id>.dp.onehouse.ai"
|
|
49
|
+
)
|
|
50
|
+
return f"https://{parts.netloc.lower()}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get(url: str) -> _oauth.HttpResult:
|
|
54
|
+
try:
|
|
55
|
+
return _oauth.get_json(url)
|
|
56
|
+
except _oauth.OAuthFlowError as err:
|
|
57
|
+
raise AiGatewayError(f"{err}. {_VPN_HINT}.") from err
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_auth_disabled(result: _oauth.HttpResult) -> bool:
|
|
61
|
+
error = result.body.get("error")
|
|
62
|
+
return result.status == 404 and isinstance(error, dict) and error.get("code") == "auth_disabled"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _protected_resource(gateway_url: str) -> dict:
|
|
66
|
+
last: Optional[_oauth.HttpResult] = None
|
|
67
|
+
for path in _PROTECTED_RESOURCE_PATHS:
|
|
68
|
+
result = _get(gateway_url + path)
|
|
69
|
+
if _is_auth_disabled(result):
|
|
70
|
+
raise AuthDisabledError(
|
|
71
|
+
f"{gateway_url} does not require authentication, so no token is needed"
|
|
72
|
+
)
|
|
73
|
+
if result.status == 200:
|
|
74
|
+
return result.body
|
|
75
|
+
last = result
|
|
76
|
+
raise AiGatewayError(
|
|
77
|
+
f"{gateway_url} published no protected-resource metadata "
|
|
78
|
+
f"({_oauth.oauth_error(last) if last else 'no response'})"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _authorization_server_metadata(issuer: str) -> dict:
|
|
83
|
+
parts = urllib.parse.urlsplit(issuer)
|
|
84
|
+
origin = f"{parts.scheme}://{parts.netloc}"
|
|
85
|
+
candidates = (
|
|
86
|
+
f"{origin}/.well-known/oauth-authorization-server{parts.path.rstrip('/')}",
|
|
87
|
+
f"{issuer.rstrip('/')}/.well-known/openid-configuration",
|
|
88
|
+
)
|
|
89
|
+
for url in candidates:
|
|
90
|
+
result = _get(url)
|
|
91
|
+
if result.status != 200:
|
|
92
|
+
continue
|
|
93
|
+
if result.body.get("issuer") != issuer:
|
|
94
|
+
raise AiGatewayError(
|
|
95
|
+
f"Authorization-server metadata at {url} names issuer "
|
|
96
|
+
f"{result.body.get('issuer')!r}, not {issuer!r}"
|
|
97
|
+
)
|
|
98
|
+
return result.body
|
|
99
|
+
raise AiGatewayError(f"No authorization-server metadata found for {issuer}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def discover(gateway_url: str, *, use_cache: bool = True) -> GatewayAuthMetadata:
|
|
103
|
+
"""The gateway's audience and Keycloak endpoints, cached per gateway URL."""
|
|
104
|
+
gateway = normalize_gateway_url(gateway_url)
|
|
105
|
+
if use_cache:
|
|
106
|
+
with _cache_lock:
|
|
107
|
+
cached = _cache.get(gateway)
|
|
108
|
+
if cached is not None:
|
|
109
|
+
return cached
|
|
110
|
+
|
|
111
|
+
resource_metadata = _protected_resource(gateway)
|
|
112
|
+
resource = resource_metadata.get("resource")
|
|
113
|
+
servers = resource_metadata.get("authorization_servers") or []
|
|
114
|
+
if not isinstance(resource, str) or not resource or not servers:
|
|
115
|
+
raise AiGatewayError(f"{gateway} published incomplete protected-resource metadata")
|
|
116
|
+
# The audience must be this gateway's, or a token minted for it is useless here.
|
|
117
|
+
if urllib.parse.urlsplit(resource).netloc.lower() != urllib.parse.urlsplit(gateway).netloc:
|
|
118
|
+
raise AiGatewayError(f"{gateway} names a resource on another host: {resource!r}")
|
|
119
|
+
issuer = str(servers[0])
|
|
120
|
+
as_metadata = _authorization_server_metadata(issuer)
|
|
121
|
+
token_endpoint = as_metadata.get("token_endpoint")
|
|
122
|
+
if not token_endpoint:
|
|
123
|
+
raise AiGatewayError(f"Authorization server {issuer} published no token_endpoint")
|
|
124
|
+
|
|
125
|
+
metadata = GatewayAuthMetadata(
|
|
126
|
+
gateway_url=gateway,
|
|
127
|
+
resource=resource,
|
|
128
|
+
issuer=issuer,
|
|
129
|
+
token_endpoint=token_endpoint,
|
|
130
|
+
authorization_endpoint=as_metadata.get("authorization_endpoint"),
|
|
131
|
+
end_session_endpoint=as_metadata.get("end_session_endpoint"),
|
|
132
|
+
)
|
|
133
|
+
with _cache_lock:
|
|
134
|
+
_cache[gateway] = metadata
|
|
135
|
+
return metadata
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def clear_cache() -> None:
|
|
139
|
+
with _cache_lock:
|
|
140
|
+
_cache.clear()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = ["GatewayAuthMetadata", "clear_cache", "discover", "normalize_gateway_url"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Exceptions raised by the AI Gateway subsystem."""
|
|
2
|
+
|
|
3
|
+
from onehouse_python_sdk.exceptions import OnehouseSdkError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AiGatewayError(OnehouseSdkError):
|
|
7
|
+
"""Discovery, minting or the sign-in failed."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthDisabledError(AiGatewayError):
|
|
11
|
+
"""The gateway has authentication off, so no token is needed."""
|