stratonext 0.0.1__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.
stratonext/__init__.py ADDED
File without changes
stratonext/auth.py ADDED
@@ -0,0 +1,287 @@
1
+ """OAuth2 user-login helpers for the stratonext CLI (AWS Cognito).
2
+
3
+ The CLI authenticates as a human user via Authorization Code + PKCE (RFC 7636)
4
+ with a localhost redirect. Cognito does not support the Device Authorization
5
+ Grant (RFC 8628), so the CLI opens a browser tab and listens on
6
+ http://localhost:19191 for the redirect (see ADR-031, ADR-035). Tokens are
7
+ cached in ~/.stratonext/credentials with refresh-token support.
8
+
9
+ Endpoints are resolved from the Cognito OIDC issuer (STRATONEXT_AUTH_AUTHORITY)
10
+ via its /.well-known/openid-configuration document. Scopes are built from the
11
+ platform resource-server identifier (STRATONEXT_AUTH_AUDIENCE) because Cognito
12
+ custom scopes must be prefixed with it, e.g.
13
+ "https://platform.prod.stratonext.com/organization:read".
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hashlib
20
+ import http.server
21
+ import json
22
+ import os
23
+ import secrets
24
+ import sys
25
+ import time
26
+ import urllib.parse
27
+ import webbrowser
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ import httpx
32
+
33
+ from .settings import resolve
34
+
35
+ CREDENTIALS_PATH = Path.home() / ".stratonext" / "credentials"
36
+
37
+ # Refresh this many seconds before actual expiry, same margin AgentCallerTokenProvider uses.
38
+ EXPIRY_MARGIN_SECONDS = 60
39
+
40
+ # Localhost redirect for the Authorization Code + PKCE flow. Must match the CLI
41
+ # client's callback URL in Cognito (cognito-stack.ts).
42
+ REDIRECT_PORT = 19191
43
+ REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}"
44
+ LOGIN_TIMEOUT = 300
45
+
46
+ # In-process cache of OIDC discovery documents, keyed by issuer.
47
+ _discovery_cache: dict[str, dict] = {}
48
+
49
+ # Production defaults — the CLI targets prod unless the matching STRATONEXT_AUTH_*
50
+ # / STRATONEXT_API_URL env var overrides it (set those for local or non-prod dev).
51
+ DEFAULT_AUTHORITY = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_26SWBDh3Q"
52
+ DEFAULT_CLI_CLIENT_ID = "60evptk8ab01298r41na2et8p8"
53
+ DEFAULT_PLATFORM_AUDIENCE = "https://platform.prod.stratonext.com"
54
+
55
+
56
+ def _user_scopes(audience: str, *, readonly: bool = False) -> str:
57
+ """Cognito user-login scopes — OIDC scopes plus resource-server-prefixed org scopes."""
58
+ scopes = ["openid", f"{audience}/organization:read"]
59
+ if not readonly:
60
+ scopes.append(f"{audience}/organization:write")
61
+ return " ".join(scopes)
62
+
63
+
64
+ async def _discover(authority: str, http_client: httpx.AsyncClient) -> dict:
65
+ """Fetch and cache the Cognito OIDC discovery document for an issuer."""
66
+ if authority in _discovery_cache:
67
+ return _discovery_cache[authority]
68
+ resp = await http_client.get(f"{authority.rstrip('/')}/.well-known/openid-configuration")
69
+ resp.raise_for_status()
70
+ doc = resp.json()
71
+ _discovery_cache[authority] = doc
72
+ return doc
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class UserAuthConfig:
77
+ """Public-client config for the OAuth2 Authorization Code + PKCE flow (RFC 7636)."""
78
+
79
+ authority: str
80
+ client_id: str
81
+ audience: str
82
+ scopes: str
83
+
84
+ @classmethod
85
+ def from_env(cls, *, readonly: bool = False) -> UserAuthConfig:
86
+ # All values default to prod; override with the matching env var for dev.
87
+ audience = resolve("STRATONEXT_AUTH_AUDIENCE", DEFAULT_PLATFORM_AUDIENCE)
88
+ return cls(
89
+ authority=resolve("STRATONEXT_AUTH_AUTHORITY", DEFAULT_AUTHORITY),
90
+ client_id=resolve("STRATONEXT_AUTH_CLIENT_ID", DEFAULT_CLI_CLIENT_ID),
91
+ audience=audience,
92
+ scopes=_user_scopes(audience, readonly=readonly),
93
+ )
94
+
95
+
96
+ async def get_access_token_user(
97
+ config: UserAuthConfig,
98
+ *,
99
+ http_client: httpx.AsyncClient | None = None,
100
+ credentials_path: Path = CREDENTIALS_PATH,
101
+ ) -> str:
102
+ """User auth — check cache, try refresh, then full Authorization Code + PKCE flow."""
103
+ creds = _read_credentials(credentials_path)
104
+
105
+ if creds and creds.get("access_token") and creds.get("expires_at", 0) > time.time():
106
+ return creds["access_token"]
107
+
108
+ if creds and creds.get("refresh_token"):
109
+ try:
110
+ return await _refresh_access_token(config, creds["refresh_token"], http_client, credentials_path)
111
+ except httpx.HTTPStatusError:
112
+ pass # refresh expired or revoked — fall through to full login
113
+
114
+ return await initiate_login(config, http_client=http_client, credentials_path=credentials_path)
115
+
116
+
117
+ async def initiate_login(
118
+ config: UserAuthConfig,
119
+ *,
120
+ http_client: httpx.AsyncClient | None = None,
121
+ credentials_path: Path = CREDENTIALS_PATH,
122
+ ) -> str:
123
+ """Run Authorization Code + PKCE: open browser, capture localhost redirect, exchange code."""
124
+ verifier = secrets.token_urlsafe(64)
125
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
126
+ state = secrets.token_urlsafe(16)
127
+
128
+ owns_client = http_client is None
129
+ http_client = http_client or httpx.AsyncClient()
130
+ try:
131
+ discovery = await _discover(config.authority, http_client)
132
+ authorize_url = discovery["authorization_endpoint"] + "?" + urllib.parse.urlencode(
133
+ {
134
+ "response_type": "code",
135
+ "client_id": config.client_id,
136
+ "redirect_uri": REDIRECT_URI,
137
+ "scope": config.scopes,
138
+ "state": state,
139
+ "code_challenge": challenge,
140
+ "code_challenge_method": "S256",
141
+ # Skip the Cognito hosted-UI provider chooser and go straight to Google
142
+ # (the CLI client only allows Google sign-in).
143
+ "identity_provider": "Google",
144
+ }
145
+ )
146
+
147
+ print(f"\nOpening browser to log in:\n {authorize_url}", file=sys.stderr)
148
+ webbrowser.open(authorize_url)
149
+ print("Waiting for authentication...", file=sys.stderr)
150
+
151
+ params = await _await_redirect(state)
152
+ if "error" in params:
153
+ raise RuntimeError(f"Login failed: {params['error'][0]}")
154
+
155
+ token_resp = await http_client.post(
156
+ discovery["token_endpoint"],
157
+ data={
158
+ "grant_type": "authorization_code",
159
+ "client_id": config.client_id,
160
+ "code": params["code"][0],
161
+ "redirect_uri": REDIRECT_URI,
162
+ "code_verifier": verifier,
163
+ },
164
+ )
165
+ token_resp.raise_for_status()
166
+ body = token_resp.json()
167
+ _write_credentials(body, credentials_path, scopes=config.scopes)
168
+ print("Logged in.", file=sys.stderr)
169
+ return body["access_token"]
170
+ finally:
171
+ if owns_client:
172
+ await http_client.aclose()
173
+
174
+
175
+ def clear_credentials(credentials_path: Path = CREDENTIALS_PATH) -> None:
176
+ """Remove cached user credentials (logout)."""
177
+ if credentials_path.exists():
178
+ credentials_path.unlink()
179
+
180
+
181
+ async def _await_redirect(state: str) -> dict[str, list[str]]:
182
+ """Serve http://localhost:19191 until the OAuth redirect arrives; return its query params."""
183
+ import asyncio
184
+
185
+ return await asyncio.get_event_loop().run_in_executor(None, _serve_until_redirect, state)
186
+
187
+
188
+ def _serve_until_redirect(state: str) -> dict[str, list[str]]:
189
+ captured: dict[str, list[str]] = {}
190
+
191
+ class _Handler(http.server.BaseHTTPRequestHandler):
192
+ def do_GET(self) -> None: # noqa: N802 (stdlib naming)
193
+ params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
194
+ if "code" in params or "error" in params:
195
+ captured.update(params)
196
+ self.send_response(200)
197
+ self.send_header("Content-Type", "text/html")
198
+ self.end_headers()
199
+ self.wfile.write(b"<html><body>Login complete. You can close this tab.</body></html>")
200
+
201
+ def log_message(self, *args: object) -> None: # silence request logging
202
+ pass
203
+
204
+ server = http.server.HTTPServer(("localhost", REDIRECT_PORT), _Handler)
205
+ server.timeout = LOGIN_TIMEOUT
206
+ try:
207
+ deadline = time.time() + LOGIN_TIMEOUT
208
+ while not captured and time.time() < deadline:
209
+ server.handle_request() # blocks for one request (favicon, etc. are ignored)
210
+ finally:
211
+ server.server_close()
212
+
213
+ if not captured:
214
+ raise RuntimeError("Login timed out. Run 'stratonext login' to try again.")
215
+ if captured.get("state", [None])[0] != state:
216
+ raise RuntimeError("Login failed: OAuth state mismatch (possible CSRF).")
217
+ return captured
218
+
219
+
220
+ async def _refresh_access_token(
221
+ config: UserAuthConfig,
222
+ refresh_token: str,
223
+ http_client: httpx.AsyncClient | None,
224
+ credentials_path: Path,
225
+ ) -> str:
226
+ owns_client = http_client is None
227
+ http_client = http_client or httpx.AsyncClient()
228
+ try:
229
+ token_endpoint = (await _discover(config.authority, http_client))["token_endpoint"]
230
+ resp = await http_client.post(
231
+ token_endpoint,
232
+ data={
233
+ "grant_type": "refresh_token",
234
+ "client_id": config.client_id,
235
+ "refresh_token": refresh_token,
236
+ },
237
+ )
238
+ resp.raise_for_status()
239
+ body = resp.json()
240
+ finally:
241
+ if owns_client:
242
+ await http_client.aclose()
243
+
244
+ # Cognito refresh responses omit the refresh_token — preserve the existing one.
245
+ _write_credentials(body, credentials_path, refresh_token=refresh_token)
246
+ return body["access_token"]
247
+
248
+
249
+ def token_scopes(access_token: str) -> str | None:
250
+ """Read the space-separated `scope` claim from a JWT access token (no signature check)."""
251
+ try:
252
+ payload = access_token.split(".")[1]
253
+ payload += "=" * (-len(payload) % 4) # restore base64 padding
254
+ return json.loads(base64.urlsafe_b64decode(payload)).get("scope")
255
+ except (IndexError, ValueError, json.JSONDecodeError):
256
+ return None
257
+
258
+
259
+ def _read_credentials(credentials_path: Path) -> dict | None:
260
+ if not credentials_path.exists():
261
+ return None
262
+ try:
263
+ return json.loads(credentials_path.read_text())
264
+ except (json.JSONDecodeError, OSError):
265
+ return None
266
+
267
+
268
+ def _write_credentials(
269
+ token_response: dict,
270
+ credentials_path: Path,
271
+ *,
272
+ scopes: str | None = None,
273
+ refresh_token: str | None = None,
274
+ ) -> None:
275
+ existing = _read_credentials(credentials_path) or {}
276
+ data = {
277
+ "access_token": token_response["access_token"],
278
+ # Refresh responses don't repeat the refresh_token; fall back to the caller's / cached one.
279
+ "refresh_token": token_response.get("refresh_token") or refresh_token or existing.get("refresh_token"),
280
+ "expires_at": time.time() + token_response.get("expires_in", 3600) - EXPIRY_MARGIN_SECONDS,
281
+ # Preserve scopes from the original login; refresh responses don't repeat them.
282
+ "scopes": scopes or existing.get("scopes"),
283
+ "user_id": existing.get("user_id"),
284
+ }
285
+ credentials_path.parent.mkdir(parents=True, exist_ok=True)
286
+ credentials_path.write_text(json.dumps(data))
287
+ credentials_path.chmod(0o600)