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.
Files changed (66) hide show
  1. sunstone_data/__init__.py +36 -0
  2. sunstone_data/_version.py +24 -0
  3. sunstone_data/auth/__init__.py +43 -0
  4. sunstone_data/auth/client_creds.py +239 -0
  5. sunstone_data/auth/pkce.py +307 -0
  6. sunstone_data/auth/store.py +97 -0
  7. sunstone_data/catalog.py +175 -0
  8. sunstone_data/cli/__init__.py +5 -0
  9. sunstone_data/cli/_app.py +26 -0
  10. sunstone_data/cli/_errors.py +31 -0
  11. sunstone_data/cli/commands/__init__.py +22 -0
  12. sunstone_data/cli/commands/data_auth.py +120 -0
  13. sunstone_data/cli/commands/data_import.py +157 -0
  14. sunstone_data/cli/commands/data_info.py +166 -0
  15. sunstone_data/cli/commands/data_publish.py +69 -0
  16. sunstone_data/cli/commands/ls.py +49 -0
  17. sunstone_data/cli/commands/prov.py +58 -0
  18. sunstone_data/cli/commands/refs.py +142 -0
  19. sunstone_data/cli/commands/show.py +139 -0
  20. sunstone_data/cli/commands/snapshots.py +77 -0
  21. sunstone_data/cli/commands/sparql.py +68 -0
  22. sunstone_data/cli/main.py +30 -0
  23. sunstone_data/config.py +250 -0
  24. sunstone_data/discovery.py +317 -0
  25. sunstone_data/env_commands.py +97 -0
  26. sunstone_data/env_section.py +86 -0
  27. sunstone_data/graph/__init__.py +36 -0
  28. sunstone_data/graph/client.py +328 -0
  29. sunstone_data/graph/dataset.py +438 -0
  30. sunstone_data/graph/terms.py +34 -0
  31. sunstone_data/identifiers.py +155 -0
  32. sunstone_data/import_/__init__.py +28 -0
  33. sunstone_data/import_/fetcher.py +259 -0
  34. sunstone_data/import_/owner.py +130 -0
  35. sunstone_data/import_/parser.py +492 -0
  36. sunstone_data/import_/pipeline.py +275 -0
  37. sunstone_data/import_/storage.py +135 -0
  38. sunstone_data/metadata/__init__.py +41 -0
  39. sunstone_data/metadata/builder.py +276 -0
  40. sunstone_data/metadata/models.py +87 -0
  41. sunstone_data/metadata/provenance.py +171 -0
  42. sunstone_data/metadata/provenance_rdf.py +102 -0
  43. sunstone_data/plugin.py +62 -0
  44. sunstone_data/publish.py +134 -0
  45. sunstone_data/query.py +624 -0
  46. sunstone_data/resolve.py +178 -0
  47. sunstone_data/url.py +78 -0
  48. sunstone_data/utils/__init__.py +12 -0
  49. sunstone_data/utils/checksum.py +14 -0
  50. sunstone_data/utils/encoding.py +16 -0
  51. sunstone_data/utils/naming.py +38 -0
  52. sunstone_data/versioning/__init__.py +17 -0
  53. sunstone_data/versioning/schema_evolution.py +180 -0
  54. sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
  55. sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
  56. sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
  57. sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
  58. sunstone_pipeline/__init__.py +1 -0
  59. sunstone_pipeline/assets/__init__.py +11 -0
  60. sunstone_pipeline/assets/mirrors.py +123 -0
  61. sunstone_pipeline/assets/transforms.py +74 -0
  62. sunstone_pipeline/definitions.py +25 -0
  63. sunstone_pipeline/io_manager.py +56 -0
  64. sunstone_pipeline/resources/__init__.py +6 -0
  65. sunstone_pipeline/resources/iceberg.py +29 -0
  66. 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
+ )