onehouse-python-sdk 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.
@@ -0,0 +1,8 @@
1
+ """Onehouse Python SDK — modular data-plane and control-plane clients."""
2
+
3
+ from . import exceptions
4
+ from .base import SqlClient
5
+ from .lakebase.client import LakebaseClient
6
+
7
+ __all__ = ["SqlClient", "LakebaseClient", "exceptions"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,50 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Optional
3
+
4
+
5
+ class SqlClient(ABC):
6
+ """Abstract base for all Onehouse SQL clients.
7
+
8
+ Subclasses implement connect() and raw_connection. The convenience methods
9
+ (execute, fetchall, fetchone) delegate to a raw DB-API 2.0 cursor so that
10
+ simple one-liners work without boilerplate, while raw_connection provides
11
+ an escape hatch for advanced use cases (streaming, metadata, etc.).
12
+ """
13
+
14
+ @abstractmethod
15
+ def connect(self, **kwargs) -> "SqlClient":
16
+ """Establish the underlying connection and return self."""
17
+ ...
18
+
19
+ @property
20
+ @abstractmethod
21
+ def raw_connection(self) -> Any:
22
+ """The underlying DB-API 2.0 connection object."""
23
+ ...
24
+
25
+ def cursor(self):
26
+ return self.raw_connection.cursor()
27
+
28
+ def execute(self, sql: str, params: Optional[tuple] = None) -> int:
29
+ cur = self.cursor()
30
+ cur.execute(sql, params)
31
+ return cur.rowcount
32
+
33
+ def fetchall(self, sql: str, params: Optional[tuple] = None) -> list:
34
+ cur = self.cursor()
35
+ cur.execute(sql, params)
36
+ return cur.fetchall()
37
+
38
+ def fetchone(self, sql: str, params: Optional[tuple] = None) -> Optional[tuple]:
39
+ cur = self.cursor()
40
+ cur.execute(sql, params)
41
+ return cur.fetchone()
42
+
43
+ def close(self) -> None:
44
+ self.raw_connection.close()
45
+
46
+ def __enter__(self) -> "SqlClient":
47
+ return self
48
+
49
+ def __exit__(self, *_) -> None:
50
+ self.close()
@@ -0,0 +1,10 @@
1
+ class OnehouseSdkError(Exception):
2
+ pass
3
+
4
+
5
+ class ConnectionError(OnehouseSdkError):
6
+ pass
7
+
8
+
9
+ class QueryError(OnehouseSdkError):
10
+ pass
@@ -0,0 +1,6 @@
1
+ """LakeBase subpackage — psycopg2 backend with browser-based auth."""
2
+
3
+ from .client import LakebaseClient
4
+ from .connection import connect
5
+
6
+ __all__ = ["LakebaseClient", "connect"]
@@ -0,0 +1,24 @@
1
+ from .browser_auth import (
2
+ AuthenticationError,
3
+ BrowserAuthHandler,
4
+ DEFAULT_CALLBACK_PATH,
5
+ DEFAULT_CALLBACK_PORT,
6
+ )
7
+ from .oidc_device_flow import OidcAuthenticationError, OidcDeviceFlowHandler
8
+ from .azure_oauth import AzureOAuthError, AzureOAuthHandler
9
+ from .saml import build_azure_entra_login_url, generate_saml_authn_request
10
+ from .local_auth_server import LocalAuthServer
11
+
12
+ __all__ = [
13
+ "AuthenticationError",
14
+ "AzureOAuthError",
15
+ "AzureOAuthHandler",
16
+ "BrowserAuthHandler",
17
+ "DEFAULT_CALLBACK_PATH",
18
+ "DEFAULT_CALLBACK_PORT",
19
+ "LocalAuthServer",
20
+ "OidcAuthenticationError",
21
+ "OidcDeviceFlowHandler",
22
+ "build_azure_entra_login_url",
23
+ "generate_saml_authn_request",
24
+ ]
@@ -0,0 +1,23 @@
1
+ """Shared SSL context that trusts the OS native cert store when available."""
2
+
3
+ import ssl
4
+
5
+
6
+ def default_ssl_context() -> ssl.SSLContext:
7
+ """Return an SSLContext for outbound HTTPS (token endpoints, IdP).
8
+
9
+ Prefers the OS native trust store via the optional ``truststore`` package
10
+ — required on corporate environments that MITM HTTPS with an internal CA
11
+ (Zscaler, Netskope, etc.) deployed to the Windows/macOS root store but
12
+ not to certifi's bundle. Falls back to certifi, then Python defaults.
13
+ """
14
+ try:
15
+ import truststore
16
+ return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
17
+ except ImportError:
18
+ pass
19
+ try:
20
+ import certifi
21
+ return ssl.create_default_context(cafile=certifi.where())
22
+ except ImportError:
23
+ return ssl.create_default_context()
@@ -0,0 +1,194 @@
1
+ """Azure AD OAuth2 Authorization Code flow (form_post response mode)."""
2
+
3
+ import base64
4
+ import gzip
5
+ import json
6
+ import logging
7
+ import os
8
+ import secrets
9
+ import urllib.parse
10
+ import urllib.request
11
+ import webbrowser
12
+ import zlib
13
+ from typing import Optional
14
+
15
+ from ._ssl import default_ssl_context
16
+ from .local_auth_server import LocalAuthServer
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _DEFAULT_TIMEOUT_SECONDS = 120
21
+
22
+
23
+ class AzureOAuthError(Exception):
24
+ pass
25
+
26
+
27
+ class AzureOAuthHandler:
28
+ """Authenticates via Azure AD OAuth2 Authorization Code flow.
29
+
30
+ Azure POSTs the authorization code (form_post response mode), so a local
31
+ callback server is required. The token exchange requests a SAML2 token
32
+ (``requested_token_type=urn:ietf:params:oauth:token-type:saml2``);
33
+ the result is GZIP-compressed and base64-encoded for the PostgreSQL password.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ tenant_id: str,
39
+ client_id: str,
40
+ client_secret: str,
41
+ scope: str = "openid",
42
+ iam_role_arn: Optional[str] = None,
43
+ callback_path: str = "/lakebase",
44
+ ) -> None:
45
+ self._tenant_id = tenant_id
46
+ self._client_id = client_id
47
+ self._client_secret = client_secret
48
+ self._scope = scope or "openid"
49
+ self._iam_role_arn = iam_role_arn
50
+ self._callback_path = callback_path
51
+
52
+ def authenticate(self, callback_port: int, auth_server: LocalAuthServer) -> dict:
53
+ """Opens browser, waits for callback, exchanges code for token.
54
+
55
+ Returns dict with keys ``user``, ``password``, and optionally ``options``.
56
+ """
57
+ state = secrets.token_urlsafe(32)
58
+ redirect_uri = f"http://localhost:{callback_port}{self._callback_path}"
59
+ auth_url = self._build_authorization_url(redirect_uri, state)
60
+
61
+ webbrowser.open(auth_url)
62
+ logger.info("=" * 80)
63
+ logger.info("AZURE AD OAUTH2 AUTHENTICATION REQUIRED")
64
+ logger.info("=" * 80)
65
+ logger.info("Please complete authentication in the opened browser window.")
66
+ logger.info("\nIf the browser didn't open automatically, visit:")
67
+ logger.info(" %s", auth_url)
68
+ logger.info("=" * 80)
69
+
70
+ event = auth_server.start()
71
+ if not event.wait(timeout=_DEFAULT_TIMEOUT_SECONDS):
72
+ raise AzureOAuthError(
73
+ f"Azure OAuth2 timed out after {_DEFAULT_TIMEOUT_SECONDS}s"
74
+ )
75
+
76
+ callback_data = auth_server.get_result()
77
+ returned_state = callback_data.get("state")
78
+ if returned_state != state:
79
+ raise AzureOAuthError("State parameter mismatch — possible CSRF attack")
80
+
81
+ # SAML response posted directly (some Azure Entra configurations)
82
+ saml_assertion = callback_data.get("SAMLResponse")
83
+ if saml_assertion:
84
+ password = base64.b64encode(
85
+ saml_assertion.encode("utf-8")
86
+ ).decode("ascii")
87
+ else:
88
+ code = callback_data.get("code")
89
+ if not code:
90
+ error = callback_data.get("error", "unknown_error")
91
+ desc = callback_data.get("error_description", "No description")
92
+ raise AzureOAuthError(f"OAuth2 error: {error} — {desc}")
93
+ password = self._exchange_code_for_token(code, redirect_uri)
94
+
95
+ result = {"user": "user", "password": password}
96
+ if self._iam_role_arn:
97
+ result["options"] = f"-c onehouse.lakebase_iam_role={self._iam_role_arn}"
98
+ return result
99
+
100
+ def _build_authorization_url(self, redirect_uri: str, state: str) -> str:
101
+ base = (
102
+ f"https://login.microsoftonline.com/{self._tenant_id}/oauth2/v2.0/authorize"
103
+ )
104
+ params = {
105
+ "client_id": self._client_id,
106
+ "response_type": "code",
107
+ "response_mode": "form_post",
108
+ "redirect_uri": redirect_uri,
109
+ "scope": self._scope,
110
+ "state": state,
111
+ }
112
+ return f"{base}?{urllib.parse.urlencode(params)}"
113
+
114
+ def _exchange_code_for_token(self, code: str, redirect_uri: str) -> str:
115
+ token_url = (
116
+ f"https://login.microsoftonline.com/{self._tenant_id}/oauth2/token"
117
+ )
118
+ params = {
119
+ "grant_type": "authorization_code",
120
+ "code": code,
121
+ "client_id": self._client_id,
122
+ "client_secret": self._client_secret,
123
+ "redirect_uri": redirect_uri,
124
+ "resource": self._client_id,
125
+ "requested_token_type": "urn:ietf:params:oauth:token-type:saml2",
126
+ }
127
+ body = urllib.parse.urlencode(params).encode()
128
+ req = urllib.request.Request(
129
+ token_url,
130
+ data=body,
131
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
132
+ method="POST",
133
+ )
134
+ try:
135
+ with urllib.request.urlopen(req, timeout=30, context=default_ssl_context()) as resp:
136
+ data = json.loads(resp.read().decode())
137
+ except urllib.error.HTTPError as exc:
138
+ raise AzureOAuthError(
139
+ f"Token exchange failed ({exc.code}): {exc.read().decode()}"
140
+ ) from exc
141
+
142
+ if "access_token" in data:
143
+ try:
144
+ # Azure may use URL-safe base64 (-/_ in place of +/) and strip padding.
145
+ tok = data["access_token"]
146
+ tok_padded = tok + "=" * (-len(tok) % 4)
147
+ saml_bytes = base64.urlsafe_b64decode(tok_padded)
148
+ try:
149
+ # Some tenants return DEFLATE-compressed SAML; others plain XML bytes.
150
+ saml_content = zlib.decompress(saml_bytes, -zlib.MAX_WBITS).decode("utf-8")
151
+ except zlib.error:
152
+ saml_content = saml_bytes.decode("utf-8")
153
+ # Wrap in a proper SAML Response envelope with the
154
+ # required <samlp:Status> Success element. STS
155
+ # AssumeRoleWithSAML requires a full Response, not a
156
+ # bare Assertion.
157
+ if "<samlp:Response" not in saml_content and "<Response" not in saml_content:
158
+ saml_content = (
159
+ '<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">'
160
+ '<samlp:Status><samlp:StatusCode '
161
+ 'Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>'
162
+ '</samlp:Status>'
163
+ f'{saml_content}'
164
+ '</samlp:Response>'
165
+ )
166
+ # Wire encoding: base64(gzip(base64(xml))) — the XML is
167
+ # base64'd first, then gzipped, then base64'd again.
168
+ # Confirmed by decoding a password emitted by v7 JDBC.
169
+ inner_b64 = base64.b64encode(saml_content.encode("utf-8")).decode("ascii")
170
+ compressed = gzip.compress(inner_b64.encode("utf-8"))
171
+ return "gzip:" + base64.b64encode(compressed).decode("ascii")
172
+ except Exception:
173
+ logger.warning("Could not parse access_token as SAML; treating as opaque token")
174
+
175
+ if "id_token" in data:
176
+ if self._iam_role_arn:
177
+ self._log_id_token_claims(data["id_token"])
178
+ return data["id_token"]
179
+
180
+ raise AzureOAuthError("Token response missing id_token and access_token")
181
+
182
+ def _log_id_token_claims(self, id_token: str) -> None:
183
+ """Decode and log JWT claims for diagnostics (no signature validation)."""
184
+ try:
185
+ parts = id_token.split(".")
186
+ if len(parts) != 3:
187
+ return
188
+ # Add padding
189
+ payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
190
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8"))
191
+ logger.info("Azure ID token claims: iss=%s aud=%s sub=%s",
192
+ payload.get("iss"), payload.get("aud"), payload.get("sub"))
193
+ except Exception:
194
+ pass
@@ -0,0 +1,249 @@
1
+ """Orchestrates all browser-based auth modes and manages a 4-minute credential cache."""
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ import webbrowser
7
+ from typing import Optional
8
+
9
+ from .azure_oauth import AzureOAuthHandler
10
+ from .local_auth_server import LocalAuthServer
11
+ from .oidc_device_flow import OidcDeviceFlowHandler
12
+ from .saml import build_azure_entra_login_url
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _CREDENTIAL_CACHE_TTL_SECONDS = 240 # 4 minutes
17
+ _AUTH_TIMEOUT_SECONDS = 120
18
+
19
+ # Property key constants (match Java driver)
20
+ _BROWSER_AUTH = "browser_auth"
21
+ _CALLBACK_PORT = "auth_callback_port"
22
+ _CALLBACK_PATH = "auth_callback_path"
23
+ _REDIRECT_URL = "auth_redirect_url"
24
+
25
+ _OIDC_CLIENT_ID = "oidc_client_id"
26
+ _OIDC_CLIENT_SECRET = "oidc_client_secret"
27
+ _OIDC_ISSUER_URL = "oidc_issuer_url"
28
+ _OIDC_IAM_ROLE = "oidc_iam_role"
29
+
30
+ _AZURE_OAUTH_TENANT_ID = "azure_oauth_tenant_id"
31
+ _AZURE_OAUTH_CLIENT_ID = "azure_oauth_client_id"
32
+ _AZURE_OAUTH_CLIENT_SECRET = "azure_oauth_client_secret"
33
+ _AZURE_OAUTH_SCOPE = "azure_oauth_scope"
34
+ _AZURE_OAUTH_IAM_ROLE = "azure_oauth_iam_role"
35
+
36
+ _AZURE_TENANT_ID = "azure_tenant_id"
37
+ _AZURE_ENTITY_ID = "azure_entity_id"
38
+
39
+ DEFAULT_CALLBACK_PORT = 8888
40
+ DEFAULT_CALLBACK_PATH = "/lakebase"
41
+
42
+
43
+ class AuthenticationError(Exception):
44
+ pass
45
+
46
+
47
+ class _CachedCredentials:
48
+ def __init__(self, credentials: dict, ttl_seconds: float) -> None:
49
+ self.credentials = dict(credentials)
50
+ self._expires_at = time.monotonic() + ttl_seconds
51
+
52
+ def is_valid(self) -> bool:
53
+ return (
54
+ "user" in self.credentials
55
+ and "password" in self.credentials
56
+ and time.monotonic() < self._expires_at
57
+ )
58
+
59
+
60
+ _cache: dict[str, _CachedCredentials] = {}
61
+ _cache_lock = threading.Lock()
62
+
63
+
64
+ def _cache_key(database_url: str, props: dict) -> str:
65
+ return "|".join([
66
+ database_url,
67
+ props.get(_REDIRECT_URL, ""),
68
+ props.get(_AZURE_TENANT_ID, ""),
69
+ props.get(_OIDC_CLIENT_ID, ""),
70
+ props.get(_AZURE_OAUTH_CLIENT_ID, ""),
71
+ ])
72
+
73
+
74
+ def _get_callback_port(props: dict) -> int:
75
+ return int(props.get(_CALLBACK_PORT, DEFAULT_CALLBACK_PORT))
76
+
77
+
78
+ def _get_callback_path(props: dict) -> str:
79
+ return props.get(_CALLBACK_PATH, DEFAULT_CALLBACK_PATH)
80
+
81
+
82
+ class BrowserAuthHandler:
83
+ """Selects and executes the correct auth flow based on connection properties.
84
+
85
+ Priority order (matches Java driver):
86
+ 1. OIDC Device Flow (oidc_client_id + oidc_issuer_url + oidc_iam_role)
87
+ 2. Azure OAuth2 (azure_oauth_tenant_id + azure_oauth_client_id + azure_oauth_client_secret)
88
+ 3. Azure Entra SAML (azure_tenant_id + azure_entity_id)
89
+ 4. External Redirect (auth_redirect_url)
90
+ 5. Built-in Form (default)
91
+ """
92
+
93
+ def authenticate(self, database_url: str, props: dict) -> dict:
94
+ """Return credentials dict (``user``, ``password``, optionally ``options``)."""
95
+ key = _cache_key(database_url, props)
96
+ with _cache_lock:
97
+ cached = _cache.get(key)
98
+ if cached and cached.is_valid():
99
+ logger.debug("Returning cached credentials")
100
+ return dict(cached.credentials)
101
+
102
+ credentials = self._do_authenticate(database_url, props)
103
+
104
+ with _cache_lock:
105
+ _cache[key] = _CachedCredentials(credentials, _CREDENTIAL_CACHE_TTL_SECONDS)
106
+
107
+ return credentials
108
+
109
+ def _do_authenticate(self, database_url: str, props: dict) -> dict:
110
+ # 1. OIDC Device Flow
111
+ oidc_client_id = props.get(_OIDC_CLIENT_ID, "").strip()
112
+ oidc_issuer_url = props.get(_OIDC_ISSUER_URL, "").strip()
113
+ oidc_iam_role = props.get(_OIDC_IAM_ROLE, "").strip()
114
+ if oidc_client_id and oidc_issuer_url and oidc_iam_role:
115
+ return self._authenticate_oidc(props)
116
+
117
+ # 2. Azure OAuth2
118
+ azure_tenant_id = props.get(_AZURE_OAUTH_TENANT_ID, "").strip()
119
+ azure_client_id = props.get(_AZURE_OAUTH_CLIENT_ID, "").strip()
120
+ azure_client_secret = props.get(_AZURE_OAUTH_CLIENT_SECRET, "").strip()
121
+ if azure_tenant_id and azure_client_id and azure_client_secret:
122
+ return self._authenticate_azure_oauth(props)
123
+
124
+ # 3. Azure Entra SAML
125
+ if props.get(_AZURE_TENANT_ID, "").strip() and props.get(_AZURE_ENTITY_ID, "").strip():
126
+ return self._authenticate_azure_entra(database_url, props)
127
+
128
+ # 4. External redirect
129
+ if props.get(_REDIRECT_URL, "").strip():
130
+ return self._authenticate_redirect(database_url, props)
131
+
132
+ # 5. Built-in form
133
+ return self._authenticate_form(database_url, props)
134
+
135
+ # ------------------------------------------------------------------
136
+ # Auth mode implementations
137
+ # ------------------------------------------------------------------
138
+
139
+ def _authenticate_oidc(self, props: dict) -> dict:
140
+ handler = OidcDeviceFlowHandler(
141
+ client_id=props[_OIDC_CLIENT_ID],
142
+ issuer_url=props[_OIDC_ISSUER_URL],
143
+ iam_role_arn=props[_OIDC_IAM_ROLE],
144
+ client_secret=props.get(_OIDC_CLIENT_SECRET),
145
+ )
146
+ try:
147
+ return handler.authenticate()
148
+ except Exception as exc:
149
+ raise AuthenticationError(f"OIDC authentication failed: {exc}") from exc
150
+
151
+ def _authenticate_azure_oauth(self, props: dict) -> dict:
152
+ port = _get_callback_port(props)
153
+ path = _get_callback_path(props)
154
+ auth_server = LocalAuthServer(port=port, callback_path=path)
155
+ handler = AzureOAuthHandler(
156
+ tenant_id=props[_AZURE_OAUTH_TENANT_ID],
157
+ client_id=props[_AZURE_OAUTH_CLIENT_ID],
158
+ client_secret=props[_AZURE_OAUTH_CLIENT_SECRET],
159
+ scope=props.get(_AZURE_OAUTH_SCOPE, "openid"),
160
+ iam_role_arn=props.get(_AZURE_OAUTH_IAM_ROLE),
161
+ callback_path=path,
162
+ )
163
+ try:
164
+ credentials = handler.authenticate(port, auth_server)
165
+ return credentials
166
+ except Exception as exc:
167
+ raise AuthenticationError(f"Azure OAuth2 authentication failed: {exc}") from exc
168
+ finally:
169
+ auth_server.stop()
170
+
171
+ def _authenticate_azure_entra(self, database_url: str, props: dict) -> dict:
172
+ port = _get_callback_port(props)
173
+ path = _get_callback_path(props)
174
+ tenant_id = props[_AZURE_TENANT_ID]
175
+ entity_id = props[_AZURE_ENTITY_ID]
176
+ acs_url = f"http://localhost:{port}{path}"
177
+
178
+ auth_server = LocalAuthServer(port=port, callback_path=path)
179
+ event = auth_server.start()
180
+ login_url = build_azure_entra_login_url(tenant_id, acs_url, entity_id)
181
+
182
+ webbrowser.open(login_url)
183
+ logger.info("=" * 80)
184
+ logger.info("AZURE ENTRA ID SAML AUTHENTICATION REQUIRED")
185
+ logger.info("=" * 80)
186
+ logger.info("Please complete authentication in the opened browser window.")
187
+ logger.info("\nIf the browser didn't open automatically, visit:")
188
+ logger.info(" %s", login_url)
189
+ logger.info("=" * 80)
190
+
191
+ try:
192
+ if not event.wait(timeout=_AUTH_TIMEOUT_SECONDS):
193
+ raise AuthenticationError(
194
+ f"Azure Entra SAML timed out after {_AUTH_TIMEOUT_SECONDS}s"
195
+ )
196
+ return auth_server.get_result()
197
+ finally:
198
+ auth_server.stop()
199
+
200
+ def _authenticate_redirect(self, database_url: str, props: dict) -> dict:
201
+ port = _get_callback_port(props)
202
+ path = _get_callback_path(props)
203
+ redirect_url = props[_REDIRECT_URL]
204
+ callback_url = f"http://localhost:{port}{path}"
205
+
206
+ # Replace {callback} placeholder or append redirect_uri param
207
+ if "{callback}" in redirect_url:
208
+ auth_url = redirect_url.replace("{callback}", callback_url)
209
+ else:
210
+ sep = "&" if "?" in redirect_url else "?"
211
+ auth_url = f"{redirect_url}{sep}redirect_uri={callback_url}"
212
+
213
+ auth_server = LocalAuthServer(port=port, callback_path=path)
214
+ event = auth_server.start()
215
+
216
+ webbrowser.open(auth_url)
217
+ logger.info("Opening browser for external redirect authentication: %s", auth_url)
218
+
219
+ try:
220
+ if not event.wait(timeout=_AUTH_TIMEOUT_SECONDS):
221
+ raise AuthenticationError(
222
+ f"External redirect auth timed out after {_AUTH_TIMEOUT_SECONDS}s"
223
+ )
224
+ return auth_server.get_result()
225
+ finally:
226
+ auth_server.stop()
227
+
228
+ def _authenticate_form(self, database_url: str, props: dict) -> dict:
229
+ port = _get_callback_port(props)
230
+ path = _get_callback_path(props)
231
+ auth_server = LocalAuthServer(port=port, callback_path=path)
232
+ event = auth_server.start()
233
+
234
+ import urllib.parse as _up
235
+ auth_url = (
236
+ f"http://localhost:{port}/auth?"
237
+ + _up.urlencode({"database": database_url, "title": "LakeBase Authentication"})
238
+ )
239
+ webbrowser.open(auth_url)
240
+ logger.info("Opening built-in login form at: %s", auth_url)
241
+
242
+ try:
243
+ if not event.wait(timeout=_AUTH_TIMEOUT_SECONDS):
244
+ raise AuthenticationError(
245
+ f"Built-in form auth timed out after {_AUTH_TIMEOUT_SECONDS}s"
246
+ )
247
+ return auth_server.get_result()
248
+ finally:
249
+ auth_server.stop()
@@ -0,0 +1,237 @@
1
+ """Local HTTP server that receives OAuth/SAML callbacks and browser form submissions."""
2
+
3
+ import base64
4
+ import gzip
5
+ import logging
6
+ import threading
7
+ import urllib.parse
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _AUTH_FORM_HTML = """\
14
+ <!DOCTYPE html>
15
+ <html>
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <title>{title}</title>
19
+ <style>
20
+ body {{ font-family: Arial, sans-serif; background: linear-gradient(135deg,#667eea,#764ba2);
21
+ display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }}
22
+ .card {{ background:#fff; border-radius:12px; padding:40px; width:360px;
23
+ box-shadow:0 20px 40px rgba(0,0,0,.2); }}
24
+ h2 {{ color:#333; margin-bottom:24px; text-align:center; }}
25
+ label {{ display:block; margin-bottom:6px; color:#555; font-size:.9rem; }}
26
+ input {{ width:100%; padding:10px 12px; margin-bottom:18px; border:1px solid #ddd;
27
+ border-radius:6px; font-size:1rem; box-sizing:border-box; }}
28
+ button {{ width:100%; padding:12px; background:#667eea; color:#fff; border:none;
29
+ border-radius:6px; font-size:1rem; cursor:pointer; }}
30
+ button:hover {{ background:#5a6fd8; }}
31
+ .db {{ color:#888; font-size:.8rem; text-align:center; margin-bottom:16px; }}
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="card">
36
+ <h2>{title}</h2>
37
+ <p class="db">{database}</p>
38
+ <form method="POST" action="/submit">
39
+ <label>Username</label>
40
+ <input type="text" name="username" required autofocus>
41
+ <label>Password</label>
42
+ <input type="password" name="password" required>
43
+ <button type="submit">Sign In</button>
44
+ </form>
45
+ </div>
46
+ </body>
47
+ </html>"""
48
+
49
+ _SUCCESS_HTML = """\
50
+ <!DOCTYPE html>
51
+ <html>
52
+ <head>
53
+ <meta charset="utf-8"><title>Authentication Successful</title>
54
+ <style>
55
+ body {{ font-family:Arial,sans-serif; background:linear-gradient(135deg,#56ab2f,#a8e063);
56
+ display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }}
57
+ .card {{ background:#fff; border-radius:12px; padding:40px; text-align:center;
58
+ box-shadow:0 20px 40px rgba(0,0,0,.2); }}
59
+ .check {{ font-size:4rem; color:#56ab2f; }}
60
+ h2 {{ color:#333; }}
61
+ p {{ color:#666; }}
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <div class="card">
66
+ <div class="check">&#10003;</div>
67
+ <h2>Authentication Successful</h2>
68
+ <p>You can close this window and return to your application.</p>
69
+ </div>
70
+ </body>
71
+ </html>"""
72
+
73
+ _ERROR_HTML = """\
74
+ <!DOCTYPE html>
75
+ <html>
76
+ <head><meta charset="utf-8"><title>Authentication Error</title></head>
77
+ <body><h2>Authentication Error</h2><p>{message}</p></body>
78
+ </html>"""
79
+
80
+
81
+ def _gzip_password(decoded_xml: str) -> str:
82
+ """GZIP-compress the SAML XML and return ``gzip:<base64>``."""
83
+ compressed = gzip.compress(decoded_xml.encode("utf-8"))
84
+ return "gzip:" + base64.b64encode(compressed).decode("ascii")
85
+
86
+
87
+ def _parse_query_string(query: Optional[str]) -> dict:
88
+ if not query:
89
+ return {}
90
+ return {k: v[0] for k, v in urllib.parse.parse_qs(query, keep_blank_values=True).items()}
91
+
92
+
93
+ class LocalAuthServer:
94
+ """Ephemeral localhost HTTP server for auth callbacks.
95
+
96
+ Starts the server in a daemon thread and returns a ``threading.Event``
97
+ + result dict via ``start()``. Call ``stop()`` when done.
98
+ """
99
+
100
+ def __init__(self, port: int = 8888, callback_path: str = "/lakebase") -> None:
101
+ self._port = port
102
+ self._callback_path = callback_path
103
+ self._result: dict = {}
104
+ self._event = threading.Event()
105
+ self._server: Optional[HTTPServer] = None
106
+
107
+ def start(self) -> threading.Event:
108
+ """Starts the server and returns an Event that is set when credentials arrive."""
109
+ handler_factory = self._make_handler()
110
+ try:
111
+ self._server = HTTPServer(("localhost", self._port), handler_factory)
112
+ except OSError as exc:
113
+ raise RuntimeError(
114
+ f"Could not start local auth server on port {self._port}: {exc}"
115
+ ) from exc
116
+
117
+ thread = threading.Thread(
118
+ target=self._server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True
119
+ )
120
+ thread.start()
121
+ logger.info("Local auth server started on port %d", self._port)
122
+ return self._event
123
+
124
+ def stop(self) -> None:
125
+ if self._server:
126
+ self._shutdown()
127
+
128
+ def _shutdown(self) -> None:
129
+ """Shut down the HTTP server in a background thread.
130
+
131
+ shutdown() blocks until serve_forever() exits its poll loop, so we
132
+ run it off the main thread to avoid stalling the caller.
133
+ """
134
+ server = self._server
135
+ self._server = None
136
+ threading.Thread(target=server.shutdown, daemon=True).start()
137
+ logger.info("Local auth server stopped")
138
+
139
+ def get_result(self) -> dict:
140
+ return self._result
141
+
142
+ def _make_handler(self):
143
+ server_ref = self
144
+
145
+ class _Handler(BaseHTTPRequestHandler):
146
+ def log_message(self, fmt, *args): # silence default request logs
147
+ pass
148
+
149
+ def _send(self, status: int, body: str, content_type: str = "text/html") -> None:
150
+ encoded = body.encode("utf-8")
151
+ self.send_response(status)
152
+ self.send_header("Content-Type", f"{content_type}; charset=utf-8")
153
+ self.send_header("Content-Length", str(len(encoded)))
154
+ self.end_headers()
155
+ self.wfile.write(encoded)
156
+
157
+ def do_GET(self):
158
+ parsed = urllib.parse.urlparse(self.path)
159
+ if parsed.path == "/auth":
160
+ params = _parse_query_string(parsed.query)
161
+ database = params.get("database", "Unknown Database")
162
+ title = params.get("title", "Database Authentication")
163
+ html = _AUTH_FORM_HTML.format(title=title, database=database)
164
+ self._send(200, html)
165
+ elif parsed.path == server_ref._callback_path:
166
+ params = _parse_query_string(parsed.query)
167
+ self._handle_callback(params)
168
+ else:
169
+ self._send(404, "Not found")
170
+
171
+ def do_POST(self):
172
+ length = int(self.headers.get("Content-Length", 0))
173
+ body = self.rfile.read(length).decode("utf-8")
174
+ params = _parse_query_string(body)
175
+
176
+ if self.path == "/submit":
177
+ username = params.get("username", "").strip()
178
+ password = params.get("password", "").strip()
179
+ if not username:
180
+ self._send(400, _ERROR_HTML.format(message="Username is required"))
181
+ return
182
+ if not password:
183
+ self._send(400, _ERROR_HTML.format(message="Password is required"))
184
+ return
185
+ server_ref._result = {"user": username, "password": password}
186
+ self._send(200, _SUCCESS_HTML)
187
+ server_ref._shutdown()
188
+ server_ref._event.set()
189
+ elif self.path == server_ref._callback_path:
190
+ self._handle_callback(params)
191
+ else:
192
+ self._send(404, "Not found")
193
+
194
+ def _handle_callback(self, params: dict) -> None:
195
+ if "SAMLResponse" in params:
196
+ logger.info("Processing SAML response")
197
+ try:
198
+ saml_b64 = params["SAMLResponse"]
199
+ decoded_xml = base64.b64decode(saml_b64).decode("utf-8")
200
+ encoded_password = _gzip_password(decoded_xml)
201
+ server_ref._result = {"user": "user", "password": encoded_password}
202
+ self._send(200, _SUCCESS_HTML)
203
+ except Exception as exc:
204
+ logger.error("Error processing SAML response: %s", exc)
205
+ self._send(500, _ERROR_HTML.format(message=str(exc)))
206
+ finally:
207
+ server_ref._shutdown()
208
+ server_ref._event.set()
209
+ elif "code" in params:
210
+ logger.info("Processing OAuth2 authorization code")
211
+ server_ref._result = dict(params)
212
+ self._send(200, _SUCCESS_HTML)
213
+ server_ref._shutdown()
214
+ server_ref._event.set()
215
+ else:
216
+ # Generic credential extraction
217
+ username = next(
218
+ (params[k] for k in ("username", "user", "login", "email") if k in params),
219
+ "user",
220
+ )
221
+ password = next(
222
+ (
223
+ params[k]
224
+ for k in ("password", "pass", "access_token", "token")
225
+ if k in params
226
+ ),
227
+ None,
228
+ )
229
+ if password:
230
+ server_ref._result = {"user": username, "password": password}
231
+ self._send(200, _SUCCESS_HTML)
232
+ server_ref._shutdown()
233
+ server_ref._event.set()
234
+ else:
235
+ self._send(400, _ERROR_HTML.format(message="No credentials received"))
236
+
237
+ return _Handler
@@ -0,0 +1,167 @@
1
+ """OIDC Device Authorization Grant (RFC 8628) for Okta/Auth0."""
2
+
3
+ import time
4
+ import urllib.parse
5
+ import urllib.request
6
+ import json
7
+ import webbrowser
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from ._ssl import default_ssl_context
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _DEFAULT_TIMEOUT_SECONDS = 300
17
+ _DEFAULT_POLL_INTERVAL_SECONDS = 5
18
+
19
+
20
+ @dataclass
21
+ class _DeviceCodeResponse:
22
+ device_code: str
23
+ verification_uri_complete: str
24
+ interval: int
25
+ expires_in: int
26
+ user_code: Optional[str] = None
27
+ verification_uri: Optional[str] = None
28
+
29
+
30
+ class OidcAuthenticationError(Exception):
31
+ pass
32
+
33
+
34
+ class OidcDeviceFlowHandler:
35
+ """Authenticates via OIDC Device Authorization Grant.
36
+
37
+ Opens a browser for the user to complete authentication, then polls the
38
+ token endpoint until the user approves or the request times out.
39
+ Sets the PostgreSQL ``options`` parameter to carry the IAM role ARN.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ client_id: str,
45
+ issuer_url: str,
46
+ iam_role_arn: str,
47
+ client_secret: Optional[str] = None,
48
+ ) -> None:
49
+ if not iam_role_arn or not iam_role_arn.strip():
50
+ raise ValueError("iam_role_arn is required for OIDC authentication")
51
+ self._client_id = client_id
52
+ self._client_secret = client_secret
53
+ self._issuer_url = issuer_url.rstrip("/")
54
+ self._iam_role_arn = iam_role_arn
55
+
56
+ def authenticate(self) -> dict:
57
+ """Returns dict with keys ``user``, ``password``, ``options``."""
58
+ try:
59
+ device_code = self._request_device_code()
60
+ self._open_browser(device_code.verification_uri_complete)
61
+ logger.info("=" * 80)
62
+ logger.info("OIDC AUTHENTICATION REQUIRED")
63
+ logger.info("=" * 80)
64
+ logger.info("Please complete authentication in the opened browser window.")
65
+ logger.info("\nIf the browser didn't open automatically, visit:")
66
+ logger.info(" %s", device_code.verification_uri_complete)
67
+ logger.info("=" * 80)
68
+ id_token = self._poll_for_token(device_code.device_code, device_code.interval)
69
+ return {
70
+ "user": "user",
71
+ "password": id_token,
72
+ "options": f"-c onehouse.lakebase_iam_role={self._iam_role_arn}",
73
+ }
74
+ except OidcAuthenticationError:
75
+ raise
76
+ except Exception as exc:
77
+ raise OidcAuthenticationError(f"OIDC authentication failed: {exc}") from exc
78
+
79
+ def _request_device_code(self) -> _DeviceCodeResponse:
80
+ url = f"{self._issuer_url}/oauth2/v1/device/authorize"
81
+ params: dict = {
82
+ "client_id": self._client_id,
83
+ "scope": "openid profile offline_access groups",
84
+ }
85
+ if self._client_secret:
86
+ params["client_secret"] = self._client_secret
87
+
88
+ body = urllib.parse.urlencode(params).encode()
89
+ req = urllib.request.Request(
90
+ url,
91
+ data=body,
92
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
93
+ method="POST",
94
+ )
95
+ with urllib.request.urlopen(req, timeout=30, context=default_ssl_context()) as resp:
96
+ data = json.loads(resp.read().decode())
97
+
98
+ return _DeviceCodeResponse(
99
+ device_code=data["device_code"],
100
+ verification_uri=data.get("verification_uri"),
101
+ verification_uri_complete=data.get(
102
+ "verification_uri_complete", data.get("verification_uri", "")
103
+ ),
104
+ user_code=data.get("user_code"),
105
+ expires_in=data.get("expires_in", _DEFAULT_TIMEOUT_SECONDS),
106
+ interval=data.get("interval", _DEFAULT_POLL_INTERVAL_SECONDS),
107
+ )
108
+
109
+ def _open_browser(self, url: str) -> None:
110
+ try:
111
+ webbrowser.open(url)
112
+ except Exception:
113
+ pass # Best-effort — user can navigate manually
114
+
115
+ def _poll_for_token(self, device_code: str, interval_seconds: int) -> str:
116
+ token_url = f"{self._issuer_url}/oauth2/v1/token"
117
+ elapsed = 0
118
+ interval = interval_seconds
119
+
120
+ while elapsed < _DEFAULT_TIMEOUT_SECONDS:
121
+ params: dict = {
122
+ "client_id": self._client_id,
123
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
124
+ "device_code": device_code,
125
+ }
126
+ if self._client_secret:
127
+ params["client_secret"] = self._client_secret
128
+
129
+ body = urllib.parse.urlencode(params).encode()
130
+ req = urllib.request.Request(
131
+ token_url,
132
+ data=body,
133
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
134
+ method="POST",
135
+ )
136
+ try:
137
+ with urllib.request.urlopen(req, timeout=30, context=default_ssl_context()) as resp:
138
+ data = json.loads(resp.read().decode())
139
+ if "id_token" in data:
140
+ return data["id_token"]
141
+ if "access_token" in data:
142
+ return data["access_token"]
143
+ raise OidcAuthenticationError(
144
+ "Token response missing id_token and access_token"
145
+ )
146
+ except urllib.error.HTTPError as exc:
147
+ if exc.code == 400:
148
+ error_body = json.loads(exc.read().decode())
149
+ error = error_body.get("error", "unknown_error")
150
+ if error == "authorization_pending":
151
+ time.sleep(interval)
152
+ elapsed += interval
153
+ continue
154
+ elif error == "slow_down":
155
+ interval += 5
156
+ time.sleep(interval)
157
+ elapsed += interval
158
+ continue
159
+ else:
160
+ raise OidcAuthenticationError(
161
+ f"OIDC token error: {error}"
162
+ ) from exc
163
+ raise
164
+
165
+ raise OidcAuthenticationError(
166
+ f"OIDC authentication timed out after {_DEFAULT_TIMEOUT_SECONDS}s"
167
+ )
@@ -0,0 +1,43 @@
1
+ """SAML AuthnRequest generation for Microsoft Entra ID (Azure AD SAML flow)."""
2
+
3
+ import base64
4
+ import uuid
5
+ import zlib
6
+ import urllib.parse
7
+ from datetime import datetime, timezone
8
+
9
+
10
+ def generate_saml_authn_request(acs_url: str, issuer: str, destination: str) -> str:
11
+ """Build, deflate-compress, base64-encode, and URL-encode a SAML AuthnRequest.
12
+
13
+ Uses raw DEFLATE (zlib wbits=-15) to match Java's ``Deflater(nowrap=True)``.
14
+ """
15
+ request_id = f"_{uuid.uuid4()}"
16
+ issue_instant = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
17
+
18
+ saml_request = (
19
+ f'<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" '
20
+ f'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" '
21
+ f'ID="{request_id}" '
22
+ f'Version="2.0" '
23
+ f'IssueInstant="{issue_instant}" '
24
+ f'Destination="{destination}" '
25
+ f'AssertionConsumerServiceURL="{acs_url}" '
26
+ f'ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">'
27
+ f'<saml:Issuer>{issuer}</saml:Issuer>'
28
+ f'<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" '
29
+ f'AllowCreate="true"/>'
30
+ f'</samlp:AuthnRequest>'
31
+ )
32
+
33
+ # Raw DEFLATE (no zlib/gzip headers) — matches Java Deflater(nowrap=true)
34
+ compressed = zlib.compress(saml_request.encode("utf-8"))[2:-4]
35
+ b64_encoded = base64.b64encode(compressed).decode("ascii")
36
+ return urllib.parse.quote_plus(b64_encoded)
37
+
38
+
39
+ def build_azure_entra_login_url(tenant_id: str, acs_url: str, entity_id: str) -> str:
40
+ """Return the full Azure Entra SAML login URL with an embedded AuthnRequest."""
41
+ destination = f"https://login.microsoftonline.com/{tenant_id}/saml2"
42
+ saml_request = generate_saml_authn_request(acs_url, entity_id, destination)
43
+ return f"{destination}?SAMLRequest={saml_request}"
@@ -0,0 +1,29 @@
1
+ from onehouse_python_sdk.base import SqlClient
2
+ from onehouse_python_sdk.exceptions import ConnectionError
3
+
4
+
5
+ class LakebaseClient(SqlClient):
6
+ """psycopg2-backed client for LakeBase (PostgreSQL-compatible).
7
+
8
+ Requires the [lakebase] extra: pip install onehouse-python-sdk[lakebase]
9
+
10
+ Supports all browser-based auth flows (OIDC, Azure OAuth2, SAML, form)
11
+ via the browser_auth=true connection parameter.
12
+ """
13
+
14
+ def __init__(self):
15
+ self._conn = None
16
+
17
+ def connect(self, dsn=None, **kwargs) -> "LakebaseClient":
18
+ try:
19
+ from onehouse_python_sdk.lakebase.connection import connect as _connect
20
+ except ImportError as exc:
21
+ raise ConnectionError(
22
+ "lakebase extra not installed. Run: pip install onehouse-python-sdk[lakebase]"
23
+ ) from exc
24
+ self._conn = _connect(dsn=dsn, **kwargs)
25
+ return self
26
+
27
+ @property
28
+ def raw_connection(self):
29
+ return self._conn
@@ -0,0 +1,145 @@
1
+ """Main connection entry point — wraps psycopg2 with browser-auth support."""
2
+
3
+ import logging
4
+ import urllib.parse
5
+ from typing import Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Property key for triggering browser auth
10
+ _BROWSER_AUTH = "browser_auth"
11
+
12
+
13
+ def connect(dsn: Optional[str] = None, **kwargs):
14
+ """Connect to a LakeBase (PostgreSQL) database, handling browser-based auth.
15
+
16
+ All arguments are forwarded to ``psycopg2.connect()``. When the
17
+ connection string or kwargs include ``browser_auth=true``, credentials
18
+ are obtained via the appropriate browser-based auth flow before the
19
+ underlying connection is established.
20
+
21
+ Returns a psycopg2 connection object.
22
+
23
+ Example::
24
+
25
+ import lakebase
26
+ conn = lakebase.connect(
27
+ host="my-cluster.lakebase.io",
28
+ port=5432,
29
+ dbname="mydb",
30
+ browser_auth="true",
31
+ oidc_client_id="...",
32
+ oidc_issuer_url="https://...",
33
+ oidc_iam_role="arn:aws:iam::...",
34
+ )
35
+ """
36
+ try:
37
+ import psycopg2
38
+ except ImportError as exc:
39
+ raise ImportError(
40
+ "psycopg2 is required: pip install psycopg2-binary"
41
+ ) from exc
42
+
43
+ # Parse DSN if provided so we can inspect/modify properties
44
+ props: dict = {}
45
+ if dsn:
46
+ props.update(_parse_dsn(dsn))
47
+ props.update(kwargs)
48
+
49
+ # Check if browser_auth is requested
50
+ browser_auth_flag = str(props.get(_BROWSER_AUTH, "false")).lower()
51
+ if browser_auth_flag in ("true", "1", "yes"):
52
+ props = _apply_browser_auth(props)
53
+
54
+ # Rebuild call args for psycopg2
55
+ return _psycopg2_connect(props)
56
+
57
+
58
+ def _apply_browser_auth(props: dict) -> dict:
59
+ """Authenticate via browser flow and inject credentials into props."""
60
+ from .auth.browser_auth import BrowserAuthHandler
61
+
62
+ database_url = _build_database_url(props)
63
+ handler = BrowserAuthHandler()
64
+ credentials = handler.authenticate(database_url, props)
65
+
66
+ result = dict(props)
67
+ result["user"] = credentials["user"]
68
+ result["password"] = credentials["password"]
69
+
70
+ # Set options from auth handler (e.g. IAM role) — merge with existing
71
+ if "options" in credentials:
72
+ existing = result.get("options", "")
73
+ result["options"] = (
74
+ f"{existing} {credentials['options']}".strip()
75
+ if existing
76
+ else credentials["options"]
77
+ )
78
+
79
+ # Strip browser-auth-specific keys that psycopg2 doesn't understand
80
+ for key in (
81
+ "browser_auth",
82
+ "auth_callback_port",
83
+ "auth_callback_path",
84
+ "auth_redirect_url",
85
+ "oidc_client_id",
86
+ "oidc_client_secret",
87
+ "oidc_issuer_url",
88
+ "oidc_iam_role",
89
+ "azure_oauth_tenant_id",
90
+ "azure_oauth_client_id",
91
+ "azure_oauth_client_secret",
92
+ "azure_oauth_scope",
93
+ "azure_oauth_iam_role",
94
+ "azure_tenant_id",
95
+ "azure_entity_id",
96
+ ):
97
+ result.pop(key, None)
98
+
99
+ return result
100
+
101
+
102
+ def _build_database_url(props: dict) -> str:
103
+ host = props.get("host", "localhost")
104
+ port = props.get("port", 5432)
105
+ dbname = props.get("dbname") or props.get("database", "")
106
+ return f"postgresql://{host}:{port}/{dbname}"
107
+
108
+
109
+ def _parse_dsn(dsn: str) -> dict:
110
+ """Parse a PostgreSQL DSN into a kwargs dict."""
111
+ # Handle postgresql:// / postgres:// URI form
112
+ if dsn.startswith(("postgresql://", "postgres://")):
113
+ parsed = urllib.parse.urlparse(dsn)
114
+ result: dict = {}
115
+ if parsed.hostname:
116
+ result["host"] = parsed.hostname
117
+ if parsed.port:
118
+ result["port"] = parsed.port
119
+ if parsed.username:
120
+ result["user"] = urllib.parse.unquote(parsed.username)
121
+ if parsed.password:
122
+ result["password"] = urllib.parse.unquote(parsed.password)
123
+ if parsed.path and parsed.path.lstrip("/"):
124
+ result["dbname"] = parsed.path.lstrip("/")
125
+ # Query string params (e.g. ?browser_auth=true&oidc_client_id=...)
126
+ if parsed.query:
127
+ for k, v in urllib.parse.parse_qs(parsed.query, keep_blank_values=True).items():
128
+ result[k] = v[0]
129
+ return result
130
+
131
+ # Handle key=value DSN form
132
+ result = {}
133
+ for token in dsn.split():
134
+ if "=" in token:
135
+ k, _, v = token.partition("=")
136
+ result[k.strip()] = v.strip().strip("'\"")
137
+ return result
138
+
139
+
140
+ def _psycopg2_connect(props: dict):
141
+ """Call psycopg2.connect() with the resolved props dict."""
142
+ import psycopg2
143
+
144
+ # psycopg2 accepts keyword arguments directly
145
+ return psycopg2.connect(**props)
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: onehouse-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Onehouse Python SDK — modular data-plane clients for LakeBase and beyond
5
+ Project-URL: Homepage, https://github.com/onehouseinc/onehouse-python-sdk
6
+ Project-URL: Repository, https://github.com/onehouseinc/onehouse-python-sdk
7
+ Project-URL: Issues, https://github.com/onehouseinc/onehouse-python-sdk/issues
8
+ Author-email: Onehouse Team <eng@onehouse.ai>
9
+ License: Proprietary
10
+ Keywords: auth,azure,lakebase,oauth2,oidc,onehouse,postgresql,saml
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
24
+ Requires-Dist: pytest-mock>=3.0; extra == 'dev'
25
+ Requires-Dist: pytest>=7.0; extra == 'dev'
26
+ Provides-Extra: lakebase
27
+ Requires-Dist: certifi>=2024.1; extra == 'lakebase'
28
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'lakebase'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # onehouse-python-sdk
32
+
33
+ Python SDK for connecting to [Onehouse](https://www.onehouse.ai) data-plane services.
34
+
35
+ The base package has **zero required dependencies**. Drivers ship as optional extras so you only install what you need.
36
+
37
+ ```bash
38
+ pip install onehouse-python-sdk[lakebase]
39
+ ```
40
+
41
+ ## LakeBase
42
+
43
+ LakeBase is Onehouse's PostgreSQL-compatible managed lakehouse engine. This SDK provides a `psycopg2`-backed client that handles the browser-based and federated authentication flows LakeBase clusters require, on top of standard username/password auth.
44
+
45
+ ### Installation
46
+
47
+ ```bash
48
+ pip install onehouse-python-sdk[lakebase]
49
+ ```
50
+
51
+ Requires Python 3.9+.
52
+
53
+ ### Quickstart
54
+
55
+ ```python
56
+ from onehouse_python_sdk import LakebaseClient
57
+
58
+ # Username / password
59
+ with LakebaseClient().connect(
60
+ host="<cluster-host>",
61
+ port=5432,
62
+ dbname="mydb",
63
+ user="admin",
64
+ password="secret",
65
+ ) as client:
66
+ rows = client.fetchall("SELECT * FROM mytable WHERE id = %s", (42,))
67
+ ```
68
+
69
+ ### Authentication flows
70
+
71
+ | Flow | Parameters |
72
+ |------|------------|
73
+ | Username / password | `user`, `password` |
74
+ | OIDC Device Flow (Okta / Auth0) | `browser_auth=true`, `oidc_client_id`, `oidc_issuer_url`, `oidc_iam_role` |
75
+ | Azure AD OAuth2 | `browser_auth=true`, `azure_oauth_tenant_id`, `azure_oauth_client_id`, `azure_oauth_client_secret` |
76
+ | Azure Entra ID SAML | `browser_auth=true`, `azure_tenant_id`, `azure_entity_id` |
77
+ | Built-in login form | `browser_auth=true` _(default when no IdP params are set)_ |
78
+ | External redirect | `browser_auth=true`, `auth_redirect_url` |
79
+
80
+ ```python
81
+ # OIDC Device Flow
82
+ client = LakebaseClient().connect(
83
+ host="<cluster-host>",
84
+ port=5432,
85
+ dbname="mydb",
86
+ browser_auth="true",
87
+ oidc_client_id="0oaXXXXX",
88
+ oidc_issuer_url="https://myorg.okta.com",
89
+ oidc_iam_role="arn:aws:iam::123456789012:role/LakeBaseRole",
90
+ )
91
+
92
+ # Azure AD OAuth2
93
+ client = LakebaseClient().connect(
94
+ host="<cluster-host>",
95
+ port=5432,
96
+ dbname="mydb",
97
+ browser_auth="true",
98
+ azure_oauth_tenant_id="your-tenant-id",
99
+ azure_oauth_client_id="your-client-id",
100
+ azure_oauth_client_secret="your-client-secret",
101
+ )
102
+ ```
103
+
104
+ DSN string form is also supported:
105
+
106
+ ```python
107
+ client = LakebaseClient().connect(
108
+ "postgresql://<cluster-host>:5432/mydb"
109
+ "?browser_auth=true"
110
+ "&oidc_client_id=0oaXXXX"
111
+ "&oidc_issuer_url=https://myorg.okta.com"
112
+ "&oidc_iam_role=arn:aws:iam::123456789012:role/LakeBaseRole"
113
+ )
114
+ ```
115
+
116
+ ### Client API
117
+
118
+ All clients implement the `SqlClient` interface:
119
+
120
+ | Method | Description |
121
+ |--------|-------------|
122
+ | `connect(dsn=None, **kwargs) → self` | Establish connection, returns self for chaining |
123
+ | `execute(sql, params)` | Run a statement, return rowcount |
124
+ | `fetchall(sql, params)` | Run a query, return `list[tuple]` |
125
+ | `fetchone(sql, params)` | Run a query, return first row or `None` |
126
+ | `cursor()` | Raw cursor for advanced use |
127
+ | `raw_connection` | Underlying `psycopg2` connection |
128
+ | `close()` | Close the connection |
129
+ | `__enter__` / `__exit__` | Context manager — closes on exit |
130
+
131
+ ### Notes
132
+
133
+ - **Credential caching** — auth tokens are cached for 4 minutes per connection parameters to avoid repeated browser prompts within the same process.
134
+ - **Callback port** — the local auth callback server defaults to port `8888` at path `/lakebase`. Override with `auth_callback_port` and `auth_callback_path`.
@@ -0,0 +1,16 @@
1
+ onehouse_python_sdk/__init__.py,sha256=dqWPAKgpPXybXnRqpmwvMLYABtvIh9bSadlMvPhX-dc,253
2
+ onehouse_python_sdk/base.py,sha256=BvgJ80SFIIZDTUbZn_RccDvprDqBI2bxHo8MDHECSfI,1508
3
+ onehouse_python_sdk/exceptions.py,sha256=5WgiJ5sTJ7hh7xdRZOhB5luGLzq0a1JnFYz6ebY34xI,143
4
+ onehouse_python_sdk/lakebase/__init__.py,sha256=mTvTzW-UOwSJR8rqulAzVnsSWwr3q_6LkhUAxwJ2PBg,181
5
+ onehouse_python_sdk/lakebase/client.py,sha256=AYFfhdB-CVqSWBJ6KKFbxLU-r-FKSpg8L-5niKfGi6Q,964
6
+ onehouse_python_sdk/lakebase/connection.py,sha256=XSgtBb1ZZorgRH1FAIIKYFfRVuN3pp5ncAbmXOvGvdg,4604
7
+ onehouse_python_sdk/lakebase/auth/__init__.py,sha256=TLx2JQubHombUAJ9anWWnGnaxzoqTJSYA129YJVtYM8,719
8
+ onehouse_python_sdk/lakebase/auth/_ssl.py,sha256=iJUCuMsWizPWarkU3pkEFFj4hnVMOR1wX6rqBsEAdCo,824
9
+ onehouse_python_sdk/lakebase/auth/azure_oauth.py,sha256=_aObA5jK6e9vx6SRJXpr5ygkiFwMcdwQOSL29zKuSpk,7943
10
+ onehouse_python_sdk/lakebase/auth/browser_auth.py,sha256=4ZsPwM3RArx5-o5pRakumjBGJ1Wd20UIUDFjuh77vEk,9282
11
+ onehouse_python_sdk/lakebase/auth/local_auth_server.py,sha256=Kx9PuKmSOC5d93gOk-xXz25nifhzydGYsvRhEbCTQok,9500
12
+ onehouse_python_sdk/lakebase/auth/oidc_device_flow.py,sha256=NpUsHpIycIiuNQEMGlNkq796hCGTVOP1sNS7rJQHwF8,6197
13
+ onehouse_python_sdk/lakebase/auth/saml.py,sha256=pRWtMkELDpALn_0rHZTFbPHArNkNW9CQbOyxHcrHqrA,1842
14
+ onehouse_python_sdk-0.1.0.dist-info/METADATA,sha256=VbnNMH9sIWbh7Qc16XBcFkMz_-HPrT8592i0XuZOmCo,4719
15
+ onehouse_python_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ onehouse_python_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any