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 +0 -0
- stratonext/auth.py +287 -0
- stratonext/cli.py +1000 -0
- stratonext/settings.py +44 -0
- stratonext-0.0.1.dist-info/METADATA +20 -0
- stratonext-0.0.1.dist-info/RECORD +8 -0
- stratonext-0.0.1.dist-info/WHEEL +4 -0
- stratonext-0.0.1.dist-info/entry_points.txt +2 -0
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)
|