sunstone-data-platform 1.0.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.
- sunstone_data/__init__.py +36 -0
- sunstone_data/_version.py +24 -0
- sunstone_data/auth/__init__.py +43 -0
- sunstone_data/auth/client_creds.py +239 -0
- sunstone_data/auth/pkce.py +307 -0
- sunstone_data/auth/store.py +97 -0
- sunstone_data/catalog.py +175 -0
- sunstone_data/cli/__init__.py +5 -0
- sunstone_data/cli/_app.py +26 -0
- sunstone_data/cli/_errors.py +31 -0
- sunstone_data/cli/commands/__init__.py +22 -0
- sunstone_data/cli/commands/data_auth.py +120 -0
- sunstone_data/cli/commands/data_import.py +157 -0
- sunstone_data/cli/commands/data_info.py +166 -0
- sunstone_data/cli/commands/data_publish.py +69 -0
- sunstone_data/cli/commands/ls.py +49 -0
- sunstone_data/cli/commands/prov.py +58 -0
- sunstone_data/cli/commands/refs.py +142 -0
- sunstone_data/cli/commands/show.py +139 -0
- sunstone_data/cli/commands/snapshots.py +77 -0
- sunstone_data/cli/commands/sparql.py +68 -0
- sunstone_data/cli/main.py +30 -0
- sunstone_data/config.py +250 -0
- sunstone_data/discovery.py +317 -0
- sunstone_data/env_commands.py +97 -0
- sunstone_data/env_section.py +86 -0
- sunstone_data/graph/__init__.py +36 -0
- sunstone_data/graph/client.py +328 -0
- sunstone_data/graph/dataset.py +438 -0
- sunstone_data/graph/terms.py +34 -0
- sunstone_data/identifiers.py +155 -0
- sunstone_data/import_/__init__.py +28 -0
- sunstone_data/import_/fetcher.py +259 -0
- sunstone_data/import_/owner.py +130 -0
- sunstone_data/import_/parser.py +492 -0
- sunstone_data/import_/pipeline.py +275 -0
- sunstone_data/import_/storage.py +135 -0
- sunstone_data/metadata/__init__.py +41 -0
- sunstone_data/metadata/builder.py +276 -0
- sunstone_data/metadata/models.py +87 -0
- sunstone_data/metadata/provenance.py +171 -0
- sunstone_data/metadata/provenance_rdf.py +102 -0
- sunstone_data/plugin.py +62 -0
- sunstone_data/publish.py +134 -0
- sunstone_data/query.py +624 -0
- sunstone_data/resolve.py +178 -0
- sunstone_data/url.py +78 -0
- sunstone_data/utils/__init__.py +12 -0
- sunstone_data/utils/checksum.py +14 -0
- sunstone_data/utils/encoding.py +16 -0
- sunstone_data/utils/naming.py +38 -0
- sunstone_data/versioning/__init__.py +17 -0
- sunstone_data/versioning/schema_evolution.py +180 -0
- sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
- sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
- sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
- sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
- sunstone_pipeline/__init__.py +1 -0
- sunstone_pipeline/assets/__init__.py +11 -0
- sunstone_pipeline/assets/mirrors.py +123 -0
- sunstone_pipeline/assets/transforms.py +74 -0
- sunstone_pipeline/definitions.py +25 -0
- sunstone_pipeline/io_manager.py +56 -0
- sunstone_pipeline/resources/__init__.py +6 -0
- sunstone_pipeline/resources/iceberg.py +29 -0
- sunstone_pipeline/resources/refs.py +88 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sunstone Data Platform - Data lakehouse with semantic metadata."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
"""Lazy imports to avoid triggering heavy dependency chains at import time.
|
|
8
|
+
|
|
9
|
+
PyIceberg's GCS module calls google.auth.default() at import time,
|
|
10
|
+
which fails in environments without GCP credentials and prevents
|
|
11
|
+
the sunstone-py plugin from loading.
|
|
12
|
+
"""
|
|
13
|
+
_lazy = {
|
|
14
|
+
"get_catalog": ("sunstone_data.catalog", "get_catalog"),
|
|
15
|
+
"create_namespace_if_not_exists": ("sunstone_data.catalog", "create_namespace_if_not_exists"),
|
|
16
|
+
"get_connection": ("sunstone_data.query", "get_connection"),
|
|
17
|
+
"list_tables": ("sunstone_data.query", "list_tables"),
|
|
18
|
+
"get_settings": ("sunstone_data.config", "get_settings"),
|
|
19
|
+
}
|
|
20
|
+
if name in _lazy:
|
|
21
|
+
module_path, attr = _lazy[name]
|
|
22
|
+
import importlib
|
|
23
|
+
|
|
24
|
+
mod = importlib.import_module(module_path)
|
|
25
|
+
return getattr(mod, attr)
|
|
26
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"get_catalog",
|
|
31
|
+
"create_namespace_if_not_exists",
|
|
32
|
+
"get_connection",
|
|
33
|
+
"list_tables",
|
|
34
|
+
"get_settings",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.0.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""OAuth2 credentials for talking to graph-server.
|
|
2
|
+
|
|
3
|
+
graph-server needs ``Authorization: Bearer <JWT>`` on every route except
|
|
4
|
+
health, the UI and public views. Two ways to get one:
|
|
5
|
+
|
|
6
|
+
- **Services** set ``client_id``/``client_secret`` and use the OAuth2
|
|
7
|
+
client-credentials grant (:mod:`sunstone_data.auth.client_creds`).
|
|
8
|
+
- **Humans** log in once; the refresh token is cached per environment by
|
|
9
|
+
:mod:`sunstone_data.auth.store` and exchanged for access tokens on demand.
|
|
10
|
+
|
|
11
|
+
:func:`resolve_access_token` picks between them, preferring an explicit
|
|
12
|
+
environment credential, so CI is never silently authenticated as whoever
|
|
13
|
+
last logged in on that machine.
|
|
14
|
+
|
|
15
|
+
Access tokens live in memory only. The refresh token is the sole thing
|
|
16
|
+
written to disk.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from sunstone_data.auth.client_creds import (
|
|
20
|
+
ClientCredentialsToken,
|
|
21
|
+
discover_token_endpoint,
|
|
22
|
+
refresh_access_token,
|
|
23
|
+
resolve_access_token,
|
|
24
|
+
)
|
|
25
|
+
from sunstone_data.auth.store import (
|
|
26
|
+
Session,
|
|
27
|
+
clear_session,
|
|
28
|
+
read_session,
|
|
29
|
+
session_path,
|
|
30
|
+
write_session,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ClientCredentialsToken",
|
|
35
|
+
"Session",
|
|
36
|
+
"clear_session",
|
|
37
|
+
"discover_token_endpoint",
|
|
38
|
+
"read_session",
|
|
39
|
+
"refresh_access_token",
|
|
40
|
+
"resolve_access_token",
|
|
41
|
+
"session_path",
|
|
42
|
+
"write_session",
|
|
43
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""OAuth2 token acquisition for graph-server clients.
|
|
2
|
+
|
|
3
|
+
Two grants, one resolver:
|
|
4
|
+
|
|
5
|
+
- ``client_credentials`` — a service authenticates as itself with the
|
|
6
|
+
``client_id``/``client_secret`` its environment configures.
|
|
7
|
+
- ``refresh_token`` — a human logged in earlier; the cached refresh token
|
|
8
|
+
(:mod:`sunstone_data.auth.store`) is exchanged for a fresh access token.
|
|
9
|
+
|
|
10
|
+
:func:`resolve_access_token` prefers the service credential, so CI never runs
|
|
11
|
+
silently as whoever last logged in on that machine (spec 180 §4).
|
|
12
|
+
|
|
13
|
+
Endpoints are discovered, not configured: graph-server publishes the
|
|
14
|
+
``issuer`` at ``/ui/config.json`` (:mod:`sunstone_data.discovery`) and
|
|
15
|
+
standard OIDC discovery on the issuer supplies the token endpoint.
|
|
16
|
+
|
|
17
|
+
Access tokens are held in memory only and never written to disk.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from sunstone_data.auth import store
|
|
28
|
+
from sunstone_data.config import Settings
|
|
29
|
+
|
|
30
|
+
#: Treat a token as expired this many seconds early, so one handed out here
|
|
31
|
+
#: does not expire mid-request.
|
|
32
|
+
EXPIRY_SKEW_SECONDS = 30.0
|
|
33
|
+
|
|
34
|
+
OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fetch_oidc_document(issuer: str, client: httpx.Client) -> tuple[dict, str]:
|
|
38
|
+
"""Fetch an issuer's OIDC discovery document.
|
|
39
|
+
|
|
40
|
+
Returns the parsed document and the URL it came from, so callers can name
|
|
41
|
+
that URL in their own errors.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
RuntimeError: The document could not be fetched or parsed.
|
|
45
|
+
"""
|
|
46
|
+
url = issuer.rstrip("/") + OIDC_DISCOVERY_PATH
|
|
47
|
+
try:
|
|
48
|
+
response = client.get(url, headers={"Accept": "application/json"})
|
|
49
|
+
response.raise_for_status()
|
|
50
|
+
payload = response.json()
|
|
51
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
52
|
+
raise RuntimeError(f"OIDC discovery failed for {url}: {e}") from e
|
|
53
|
+
return (payload if isinstance(payload, dict) else {}), url
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def discover_token_endpoint(issuer: str, client: httpx.Client) -> str:
|
|
57
|
+
"""Return the OAuth2 token endpoint for an OIDC issuer.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
issuer: OIDC issuer URL, with or without a trailing slash.
|
|
61
|
+
client: HTTP client to use for the request.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
RuntimeError: The discovery document could not be fetched or has no
|
|
65
|
+
usable ``token_endpoint``.
|
|
66
|
+
"""
|
|
67
|
+
payload, url = _fetch_oidc_document(issuer, client)
|
|
68
|
+
token_endpoint = payload.get("token_endpoint")
|
|
69
|
+
if not token_endpoint:
|
|
70
|
+
raise RuntimeError(f"OIDC discovery document at {url} has no token_endpoint")
|
|
71
|
+
return token_endpoint
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def discover_authorization_endpoint(issuer: str, client: httpx.Client) -> str:
|
|
75
|
+
"""Return the OAuth2 authorization endpoint for an OIDC issuer.
|
|
76
|
+
|
|
77
|
+
This is where the PKCE flow sends the user's browser.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
issuer: OIDC issuer URL, with or without a trailing slash.
|
|
81
|
+
client: HTTP client to use for the request.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
RuntimeError: The discovery document could not be fetched or has no
|
|
85
|
+
usable ``authorization_endpoint``.
|
|
86
|
+
"""
|
|
87
|
+
payload, url = _fetch_oidc_document(issuer, client)
|
|
88
|
+
authorization_endpoint = payload.get("authorization_endpoint")
|
|
89
|
+
if not authorization_endpoint:
|
|
90
|
+
raise RuntimeError(f"OIDC discovery document at {url} has no authorization_endpoint")
|
|
91
|
+
return authorization_endpoint
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _post_token(token_endpoint: str, form: dict[str, str], client: httpx.Client) -> tuple[str, float]:
|
|
95
|
+
"""POST a token request and return ``(access_token, expires_at)``.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
RuntimeError: The request failed or the response carried no
|
|
99
|
+
``access_token``.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
response = client.post(
|
|
103
|
+
token_endpoint,
|
|
104
|
+
data=form,
|
|
105
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
106
|
+
)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
payload = response.json()
|
|
109
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
110
|
+
raise RuntimeError(f"Token request to {token_endpoint} failed: {e}") from e
|
|
111
|
+
access_token = payload.get("access_token") if isinstance(payload, dict) else None
|
|
112
|
+
if not access_token:
|
|
113
|
+
raise RuntimeError(f"Token response from {token_endpoint} has no access_token")
|
|
114
|
+
try:
|
|
115
|
+
expires_in = float(payload.get("expires_in") or 0)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
expires_in = 0.0
|
|
118
|
+
return access_token, time.time() + expires_in
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ClientCredentialsToken:
|
|
122
|
+
"""Client-credentials access tokens for one service identity.
|
|
123
|
+
|
|
124
|
+
Holds the current access token in memory and re-fetches it when it is
|
|
125
|
+
within :data:`EXPIRY_SKEW_SECONDS` of expiry. Nothing is written to disk.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, issuer: str, client_id: str, client_secret: str) -> None:
|
|
129
|
+
"""Args: the OIDC issuer and the service's OAuth2 client credentials."""
|
|
130
|
+
self.issuer = issuer
|
|
131
|
+
self.client_id = client_id
|
|
132
|
+
self.client_secret = client_secret
|
|
133
|
+
self._access_token: str | None = None
|
|
134
|
+
self._expires_at: float = 0.0
|
|
135
|
+
|
|
136
|
+
def access_token(self, client: httpx.Client) -> str:
|
|
137
|
+
"""Return a valid access token, fetching a new one only if needed.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
client: HTTP client used for discovery and the token request.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
RuntimeError: Discovery or the token request failed.
|
|
144
|
+
"""
|
|
145
|
+
if self._access_token is not None and time.time() < self._expires_at - EXPIRY_SKEW_SECONDS:
|
|
146
|
+
return self._access_token
|
|
147
|
+
token_endpoint = discover_token_endpoint(self.issuer, client)
|
|
148
|
+
self._access_token, self._expires_at = _post_token(
|
|
149
|
+
token_endpoint,
|
|
150
|
+
{
|
|
151
|
+
"grant_type": "client_credentials",
|
|
152
|
+
"client_id": self.client_id,
|
|
153
|
+
"client_secret": self.client_secret,
|
|
154
|
+
},
|
|
155
|
+
client,
|
|
156
|
+
)
|
|
157
|
+
return self._access_token
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def refresh_access_token(issuer: str, client_id: str, refresh_token: str, client: httpx.Client) -> str:
|
|
161
|
+
"""Exchange a cached human refresh token for a fresh access token.
|
|
162
|
+
|
|
163
|
+
The access token is returned, never persisted; only the refresh token
|
|
164
|
+
lives on disk.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
RuntimeError: Discovery or the token request failed.
|
|
168
|
+
"""
|
|
169
|
+
token_endpoint = discover_token_endpoint(issuer, client)
|
|
170
|
+
access_token, _expires_at = _post_token(
|
|
171
|
+
token_endpoint,
|
|
172
|
+
{
|
|
173
|
+
"grant_type": "refresh_token",
|
|
174
|
+
"client_id": client_id,
|
|
175
|
+
"refresh_token": refresh_token,
|
|
176
|
+
},
|
|
177
|
+
client,
|
|
178
|
+
)
|
|
179
|
+
return access_token
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def discovered_issuer(env_name: str, graph_url: str) -> str:
|
|
183
|
+
"""Return the OIDC issuer graph-server publishes for an environment.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
RuntimeError: No issuer could be discovered.
|
|
187
|
+
"""
|
|
188
|
+
from sunstone_data.discovery import get_discovered
|
|
189
|
+
|
|
190
|
+
discovered = get_discovered(env_name, graph_url)
|
|
191
|
+
if discovered is None or not discovered.issuer:
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
f"No OIDC issuer discovered for environment {env_name!r}; cannot authenticate (graph_url={graph_url!r})"
|
|
194
|
+
)
|
|
195
|
+
return discovered.issuer
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def resolve_access_token(settings: Settings, env_name: str, client: httpx.Client) -> str | None:
|
|
199
|
+
"""Return a bearer token for graph-server, or None if none is configured.
|
|
200
|
+
|
|
201
|
+
Precedence (spec 180 §4): an explicit environment credential beats a
|
|
202
|
+
cached human session, so a service or CI run is never silently
|
|
203
|
+
authenticated as whoever last logged in on that machine.
|
|
204
|
+
|
|
205
|
+
``SUNSTONE_ACCESS_TOKEN`` sits above both. It is for callers that already
|
|
206
|
+
hold a token and have no reachable identity provider — CI, the compose
|
|
207
|
+
stack, a short-lived job handed a token by whatever started it. The token
|
|
208
|
+
is used as-is: it is never refreshed, so a caller supplying one owns its
|
|
209
|
+
lifetime. A blank value counts as unset.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
settings: Resolved settings — ``client_id``/``client_secret`` select
|
|
213
|
+
the service flow, ``graph_url`` drives issuer discovery.
|
|
214
|
+
env_name: Environment whose session cache to consult.
|
|
215
|
+
client: HTTP client used for discovery and token requests.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
An access token, or ``None`` when neither a service credential nor a
|
|
219
|
+
cached login exists. What to do about that — error, prompt to log
|
|
220
|
+
in — is the caller's call.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
RuntimeError: A credential exists but no issuer could be discovered,
|
|
224
|
+
or the token request failed.
|
|
225
|
+
"""
|
|
226
|
+
env_token = os.environ.get("SUNSTONE_ACCESS_TOKEN", "").strip()
|
|
227
|
+
if env_token:
|
|
228
|
+
return env_token
|
|
229
|
+
|
|
230
|
+
if settings.client_id and settings.client_secret:
|
|
231
|
+
issuer = discovered_issuer(env_name, settings.graph_url)
|
|
232
|
+
return ClientCredentialsToken(issuer, settings.client_id, settings.client_secret).access_token(client)
|
|
233
|
+
|
|
234
|
+
session = store.read_session(env_name)
|
|
235
|
+
if session is not None:
|
|
236
|
+
issuer = session.issuer or discovered_issuer(env_name, settings.graph_url)
|
|
237
|
+
return refresh_access_token(issuer, session.client_id, session.refresh_token, client)
|
|
238
|
+
|
|
239
|
+
return None
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Human sign-in: OAuth2 authorization code with PKCE on a loopback redirect.
|
|
2
|
+
|
|
3
|
+
PKCE (Proof Key for Code Exchange, RFC 7636) lets a client with no secret —
|
|
4
|
+
like a CLI — prove that the app redeeming an authorization code is the same
|
|
5
|
+
app that asked for it. The client invents a random ``code_verifier``, sends
|
|
6
|
+
only its SHA-256 hash (the ``code_challenge``) to the authorization endpoint,
|
|
7
|
+
and reveals the verifier when it redeems the code.
|
|
8
|
+
|
|
9
|
+
The browser hands the code back over a redirect to ``localhost`` on one of
|
|
10
|
+
the ports registered on the Keycloak client (:data:`LOOPBACK_PORTS`), so
|
|
11
|
+
nothing leaves the machine. Keycloak matches redirect URIs exactly and has no
|
|
12
|
+
wildcard for ports, so the port cannot be ephemeral. The port is bound
|
|
13
|
+
*before* the authorization URL is built, because the ``redirect_uri`` must
|
|
14
|
+
name it.
|
|
15
|
+
|
|
16
|
+
This is the flow behind ``sunstone data login`` (spec 180 §4). The public
|
|
17
|
+
client it signs in as is the one graph-server publishes as ``clientId`` in
|
|
18
|
+
``/ui/config.json``, the same one the graph explorer UI uses.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import hashlib
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
import webbrowser
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
31
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
|
|
35
|
+
#: Loopback ports registered as redirect URIs on the public Keycloak client
|
|
36
|
+
#: (``http://localhost:<port>``, no path). Tried in order.
|
|
37
|
+
LOOPBACK_PORTS = (8000, 18000)
|
|
38
|
+
|
|
39
|
+
#: Give up if nobody completes the browser step within this many seconds.
|
|
40
|
+
CALLBACK_TIMEOUT_SECONDS = 300.0
|
|
41
|
+
|
|
42
|
+
_BROWSER_MESSAGE = "Signed in to Sunstone. You can close this window."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def generate_code_verifier() -> str:
|
|
46
|
+
"""Return a fresh PKCE ``code_verifier``.
|
|
47
|
+
|
|
48
|
+
``token_urlsafe(64)`` yields 86 unreserved characters, inside RFC 7636's
|
|
49
|
+
43-128 range.
|
|
50
|
+
"""
|
|
51
|
+
return secrets.token_urlsafe(64)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def derive_code_challenge(verifier: str) -> str:
|
|
55
|
+
"""Return the ``S256`` ``code_challenge`` for a verifier.
|
|
56
|
+
|
|
57
|
+
That is unpadded base64url of the SHA-256 of the verifier's ASCII bytes.
|
|
58
|
+
"""
|
|
59
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
60
|
+
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def generate_state() -> str:
|
|
64
|
+
"""Return a random ``state`` value, which ties a callback to this request."""
|
|
65
|
+
return secrets.token_urlsafe(32)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_authorization_url(
|
|
69
|
+
authorization_endpoint: str,
|
|
70
|
+
*,
|
|
71
|
+
client_id: str,
|
|
72
|
+
redirect_uri: str,
|
|
73
|
+
state: str,
|
|
74
|
+
code_challenge: str,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Build the URL the user's browser is sent to in order to sign in."""
|
|
77
|
+
query = urlencode(
|
|
78
|
+
{
|
|
79
|
+
"response_type": "code",
|
|
80
|
+
"client_id": client_id,
|
|
81
|
+
"redirect_uri": redirect_uri,
|
|
82
|
+
"state": state,
|
|
83
|
+
"code_challenge": code_challenge,
|
|
84
|
+
"code_challenge_method": "S256",
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
separator = "&" if urlparse(authorization_endpoint).query else "?"
|
|
88
|
+
return f"{authorization_endpoint}{separator}{query}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class CallbackResult:
|
|
93
|
+
"""What the browser handed back on the loopback redirect."""
|
|
94
|
+
|
|
95
|
+
code: str | None = None
|
|
96
|
+
"""Authorization code, when sign-in succeeded."""
|
|
97
|
+
|
|
98
|
+
state: str | None = None
|
|
99
|
+
"""The ``state`` echoed back; must match the one that was sent."""
|
|
100
|
+
|
|
101
|
+
error: str | None = None
|
|
102
|
+
"""OAuth2 error code, when the provider refused."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_callback_query(query_string: str) -> CallbackResult:
|
|
106
|
+
"""Parse a redirect's query string into a :class:`CallbackResult`.
|
|
107
|
+
|
|
108
|
+
Kept separate from the HTTP server so it can be tested without a socket.
|
|
109
|
+
"""
|
|
110
|
+
params = parse_qs(query_string)
|
|
111
|
+
|
|
112
|
+
def first(name: str) -> str | None:
|
|
113
|
+
values = params.get(name)
|
|
114
|
+
return values[0] if values else None
|
|
115
|
+
|
|
116
|
+
return CallbackResult(code=first("code"), state=first("state"), error=first("error"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _handler_class(sink: dict[str, CallbackResult]) -> type[BaseHTTPRequestHandler]:
|
|
120
|
+
"""Build a one-shot request handler that drops its result into ``sink``."""
|
|
121
|
+
|
|
122
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
123
|
+
def do_GET(self) -> None: # noqa: N802 (stdlib naming)
|
|
124
|
+
sink["result"] = parse_callback_query(urlparse(self.path).query)
|
|
125
|
+
body = _BROWSER_MESSAGE.encode("utf-8")
|
|
126
|
+
self.send_response(200)
|
|
127
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
128
|
+
self.send_header("Content-Length", str(len(body)))
|
|
129
|
+
self.end_headers()
|
|
130
|
+
self.wfile.write(body)
|
|
131
|
+
|
|
132
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
133
|
+
"""Silence the stdlib's stderr access log."""
|
|
134
|
+
|
|
135
|
+
return CallbackHandler
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def start_loopback_server(
|
|
139
|
+
ports: tuple[int, ...] = LOOPBACK_PORTS,
|
|
140
|
+
) -> tuple[HTTPServer, dict[str, CallbackResult]]:
|
|
141
|
+
"""Bind a loopback callback server on the first free registered port.
|
|
142
|
+
|
|
143
|
+
The server is bound but not yet serving, so the caller can read
|
|
144
|
+
``server.server_address[1]`` to build the exact ``redirect_uri`` before
|
|
145
|
+
sending the user to the browser.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The server and a dict that gains a ``"result"`` key holding a
|
|
149
|
+
:class:`CallbackResult` once one request has been handled.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
RuntimeError: Every port in ``ports`` is in use.
|
|
153
|
+
"""
|
|
154
|
+
sink: dict[str, CallbackResult] = {}
|
|
155
|
+
handler = _handler_class(sink)
|
|
156
|
+
for port in ports:
|
|
157
|
+
try:
|
|
158
|
+
return HTTPServer(("127.0.0.1", port), handler), sink
|
|
159
|
+
except OSError:
|
|
160
|
+
continue
|
|
161
|
+
raise RuntimeError(f"No free loopback port for sign-in; tried {', '.join(map(str, ports))}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def await_callback(server: HTTPServer, sink: dict[str, CallbackResult]) -> CallbackResult:
|
|
165
|
+
"""Serve exactly one request and return what it carried.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
RuntimeError: Nothing arrived within :data:`CALLBACK_TIMEOUT_SECONDS`.
|
|
169
|
+
"""
|
|
170
|
+
server.timeout = CALLBACK_TIMEOUT_SECONDS
|
|
171
|
+
server.handle_request()
|
|
172
|
+
result = sink.get("result")
|
|
173
|
+
if result is None:
|
|
174
|
+
raise RuntimeError(f"No sign-in callback received within {CALLBACK_TIMEOUT_SECONDS:.0f}s")
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def verify_callback(result: CallbackResult, expected_state: str) -> str:
|
|
179
|
+
"""Check a callback and return its authorization code.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
RuntimeError: The ``state`` did not match the one sent (a possible
|
|
183
|
+
cross-site request forgery), the provider returned an error, or
|
|
184
|
+
no code came back.
|
|
185
|
+
"""
|
|
186
|
+
if result.state != expected_state:
|
|
187
|
+
raise RuntimeError("Sign-in callback state did not match the request; discarding it")
|
|
188
|
+
if result.error:
|
|
189
|
+
raise RuntimeError(f"Sign-in failed: {result.error}")
|
|
190
|
+
if not result.code:
|
|
191
|
+
raise RuntimeError("Sign-in callback carried no authorization code")
|
|
192
|
+
return result.code
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class TokenResponse:
|
|
197
|
+
"""Tokens minted by the authorization-code exchange."""
|
|
198
|
+
|
|
199
|
+
access_token: str
|
|
200
|
+
"""Bearer token for graph-server. Held in memory only."""
|
|
201
|
+
|
|
202
|
+
refresh_token: str
|
|
203
|
+
"""Long-lived token, the only part cached on disk."""
|
|
204
|
+
|
|
205
|
+
expires_at: float
|
|
206
|
+
"""Unix time the access token expires."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def exchange_code(
|
|
210
|
+
token_endpoint: str,
|
|
211
|
+
*,
|
|
212
|
+
code: str,
|
|
213
|
+
code_verifier: str,
|
|
214
|
+
redirect_uri: str,
|
|
215
|
+
client_id: str,
|
|
216
|
+
client: httpx.Client,
|
|
217
|
+
) -> TokenResponse:
|
|
218
|
+
"""Redeem an authorization code for tokens.
|
|
219
|
+
|
|
220
|
+
No client secret is sent: the client is public, and the
|
|
221
|
+
``code_verifier`` is what proves this is the app that asked for the code.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
RuntimeError: The request failed, or the response lacked an
|
|
225
|
+
``access_token`` or ``refresh_token``.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
response = client.post(
|
|
229
|
+
token_endpoint,
|
|
230
|
+
data={
|
|
231
|
+
"grant_type": "authorization_code",
|
|
232
|
+
"code": code,
|
|
233
|
+
"redirect_uri": redirect_uri,
|
|
234
|
+
"client_id": client_id,
|
|
235
|
+
"code_verifier": code_verifier,
|
|
236
|
+
},
|
|
237
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
238
|
+
)
|
|
239
|
+
response.raise_for_status()
|
|
240
|
+
payload = response.json()
|
|
241
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
242
|
+
raise RuntimeError(f"Token request to {token_endpoint} failed: {e}") from e
|
|
243
|
+
if not isinstance(payload, dict):
|
|
244
|
+
payload = {}
|
|
245
|
+
access_token = payload.get("access_token")
|
|
246
|
+
refresh_token = payload.get("refresh_token")
|
|
247
|
+
if not access_token:
|
|
248
|
+
raise RuntimeError(f"Token response from {token_endpoint} has no access_token")
|
|
249
|
+
if not refresh_token:
|
|
250
|
+
raise RuntimeError(f"Token response from {token_endpoint} has no refresh_token; cannot cache a session")
|
|
251
|
+
try:
|
|
252
|
+
expires_in = float(payload.get("expires_in") or 0)
|
|
253
|
+
except (TypeError, ValueError):
|
|
254
|
+
expires_in = 0.0
|
|
255
|
+
return TokenResponse(access_token, refresh_token, time.time() + expires_in)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def login(
|
|
259
|
+
*,
|
|
260
|
+
authorization_endpoint: str,
|
|
261
|
+
token_endpoint: str,
|
|
262
|
+
client_id: str,
|
|
263
|
+
client: httpx.Client,
|
|
264
|
+
open_browser: Callable[[str], object] = webbrowser.open,
|
|
265
|
+
) -> TokenResponse:
|
|
266
|
+
"""Run the full PKCE sign-in and return the resulting tokens.
|
|
267
|
+
|
|
268
|
+
Binds a loopback port, sends the user's browser to the authorization
|
|
269
|
+
endpoint, waits for the one redirect back, checks the ``state``, and
|
|
270
|
+
redeems the code.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
authorization_endpoint: Where to send the browser, from OIDC discovery.
|
|
274
|
+
token_endpoint: Where to redeem the code, from OIDC discovery.
|
|
275
|
+
client_id: The public OAuth2 client to sign in as.
|
|
276
|
+
client: HTTP client for the token exchange.
|
|
277
|
+
open_browser: How to show the user the authorization URL. Injected so
|
|
278
|
+
tests need no browser.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
RuntimeError: Sign-in was refused, timed out, or the callback failed
|
|
282
|
+
its ``state`` check.
|
|
283
|
+
"""
|
|
284
|
+
verifier = generate_code_verifier()
|
|
285
|
+
state = generate_state()
|
|
286
|
+
server, sink = start_loopback_server()
|
|
287
|
+
try:
|
|
288
|
+
redirect_uri = f"http://localhost:{server.server_address[1]}"
|
|
289
|
+
url = build_authorization_url(
|
|
290
|
+
authorization_endpoint,
|
|
291
|
+
client_id=client_id,
|
|
292
|
+
redirect_uri=redirect_uri,
|
|
293
|
+
state=state,
|
|
294
|
+
code_challenge=derive_code_challenge(verifier),
|
|
295
|
+
)
|
|
296
|
+
open_browser(url)
|
|
297
|
+
code = verify_callback(await_callback(server, sink), state)
|
|
298
|
+
finally:
|
|
299
|
+
server.server_close()
|
|
300
|
+
return exchange_code(
|
|
301
|
+
token_endpoint,
|
|
302
|
+
code=code,
|
|
303
|
+
code_verifier=verifier,
|
|
304
|
+
redirect_uri=redirect_uri,
|
|
305
|
+
client_id=client_id,
|
|
306
|
+
client=client,
|
|
307
|
+
)
|