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.
- onehouse_python_sdk/__init__.py +8 -0
- onehouse_python_sdk/base.py +50 -0
- onehouse_python_sdk/exceptions.py +10 -0
- onehouse_python_sdk/lakebase/__init__.py +6 -0
- onehouse_python_sdk/lakebase/auth/__init__.py +24 -0
- onehouse_python_sdk/lakebase/auth/_ssl.py +23 -0
- onehouse_python_sdk/lakebase/auth/azure_oauth.py +194 -0
- onehouse_python_sdk/lakebase/auth/browser_auth.py +249 -0
- onehouse_python_sdk/lakebase/auth/local_auth_server.py +237 -0
- onehouse_python_sdk/lakebase/auth/oidc_device_flow.py +167 -0
- onehouse_python_sdk/lakebase/auth/saml.py +43 -0
- onehouse_python_sdk/lakebase/client.py +29 -0
- onehouse_python_sdk/lakebase/connection.py +145 -0
- onehouse_python_sdk-0.1.0.dist-info/METADATA +134 -0
- onehouse_python_sdk-0.1.0.dist-info/RECORD +16 -0
- onehouse_python_sdk-0.1.0.dist-info/WHEEL +4 -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,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">✓</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,,
|