lightnow-cli 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.
- lightnow_cli/__init__.py +3 -0
- lightnow_cli/authenticated_http.py +134 -0
- lightnow_cli/client.py +250 -0
- lightnow_cli/commands/__init__.py +1 -0
- lightnow_cli/commands/auth.py +500 -0
- lightnow_cli/commands/integrations.py +658 -0
- lightnow_cli/commands/publish.py +111 -0
- lightnow_cli/commands/query.py +268 -0
- lightnow_cli/commands/runner.py +399 -0
- lightnow_cli/commands/validate.py +78 -0
- lightnow_cli/config.py +152 -0
- lightnow_cli/main.py +54 -0
- lightnow_cli/tls.py +8 -0
- lightnow_cli/validation.py +238 -0
- lightnow_cli-1.0.0.dist-info/METADATA +143 -0
- lightnow_cli-1.0.0.dist-info/RECORD +19 -0
- lightnow_cli-1.0.0.dist-info/WHEEL +4 -0
- lightnow_cli-1.0.0.dist-info/entry_points.txt +2 -0
- lightnow_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Authentication commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from typing import Any, Dict, Optional, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import jwt
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from typing_extensions import Annotated
|
|
16
|
+
|
|
17
|
+
from ..config import (
|
|
18
|
+
DEFAULT_CLIENT_ID,
|
|
19
|
+
DEFAULT_ISSUER,
|
|
20
|
+
LOCAL_ISSUER,
|
|
21
|
+
LOCAL_REGISTRY_API_URL,
|
|
22
|
+
config_manager,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
app = typer.Typer(help="Authentication commands")
|
|
27
|
+
|
|
28
|
+
ACCESS_TOKEN_EXPIRED_MESSAGE = (
|
|
29
|
+
"Access token has expired. Please run 'lightnow login' again."
|
|
30
|
+
)
|
|
31
|
+
NOT_AUTHENTICATED_MESSAGE = "Not authenticated. Run 'lightnow login' first."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthError(Exception):
|
|
35
|
+
"""Authentication error."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AccessTokenExpired(AuthError):
|
|
41
|
+
"""The stored access token is expired and cannot be refreshed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def open_authorization_url(verification_uri: str) -> bool:
|
|
45
|
+
"""Open the device authorization URL in the user's browser."""
|
|
46
|
+
return webbrowser.open(verification_uri, new=2, autoraise=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_pkce_verifier() -> str:
|
|
50
|
+
"""Generate a PKCE verifier suitable for public-device clients."""
|
|
51
|
+
return secrets.token_urlsafe(64)[:128]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pkce_challenge(verifier: str) -> str:
|
|
55
|
+
"""Return the S256 PKCE challenge for a verifier."""
|
|
56
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
57
|
+
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def discover_oidc_endpoints(issuer: str) -> Dict[str, str]:
|
|
61
|
+
"""Discover the OIDC endpoints required by the CLI."""
|
|
62
|
+
async with httpx.AsyncClient() as client:
|
|
63
|
+
try:
|
|
64
|
+
discovery_response = await client.get(
|
|
65
|
+
f"{issuer}/.well-known/openid-configuration"
|
|
66
|
+
)
|
|
67
|
+
discovery_response.raise_for_status()
|
|
68
|
+
discovery = discovery_response.json()
|
|
69
|
+
if not isinstance(discovery, dict):
|
|
70
|
+
raise AuthError("OIDC discovery response is not a JSON object")
|
|
71
|
+
except httpx.HTTPError as e:
|
|
72
|
+
raise AuthError(f"Failed to discover OIDC endpoints: {e}") from e
|
|
73
|
+
|
|
74
|
+
device_authorization_endpoint = discovery.get("device_authorization_endpoint")
|
|
75
|
+
token_endpoint = discovery.get("token_endpoint")
|
|
76
|
+
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
|
77
|
+
|
|
78
|
+
if not device_authorization_endpoint or not token_endpoint:
|
|
79
|
+
raise AuthError("OIDC provider does not support device code flow")
|
|
80
|
+
|
|
81
|
+
endpoints = {
|
|
82
|
+
"device_authorization_endpoint": cast(str, device_authorization_endpoint),
|
|
83
|
+
"token_endpoint": cast(str, token_endpoint),
|
|
84
|
+
}
|
|
85
|
+
if isinstance(userinfo_endpoint, str) and userinfo_endpoint:
|
|
86
|
+
endpoints["userinfo_endpoint"] = userinfo_endpoint
|
|
87
|
+
return endpoints
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def device_code_flow(issuer: str, client_id: str) -> Dict[str, Any]:
|
|
91
|
+
"""Perform OIDC Device Code Flow authentication."""
|
|
92
|
+
endpoints = await discover_oidc_endpoints(issuer)
|
|
93
|
+
code_verifier = generate_pkce_verifier()
|
|
94
|
+
async with httpx.AsyncClient() as client:
|
|
95
|
+
# Start device authorization
|
|
96
|
+
try:
|
|
97
|
+
device_response = await client.post(
|
|
98
|
+
endpoints["device_authorization_endpoint"],
|
|
99
|
+
data={
|
|
100
|
+
"client_id": client_id,
|
|
101
|
+
"scope": "openid profile email",
|
|
102
|
+
"code_challenge": pkce_challenge(code_verifier),
|
|
103
|
+
"code_challenge_method": "S256",
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
device_response.raise_for_status()
|
|
107
|
+
device_data = device_response.json()
|
|
108
|
+
|
|
109
|
+
except httpx.HTTPError as e:
|
|
110
|
+
raise AuthError(f"Failed to start device authorization: {e}")
|
|
111
|
+
|
|
112
|
+
# Show user instructions
|
|
113
|
+
console.print("\n[bold blue]Device Authorization Required[/bold blue]")
|
|
114
|
+
verification_uri = (
|
|
115
|
+
device_data.get("verification_uri_complete")
|
|
116
|
+
or device_data["verification_uri"]
|
|
117
|
+
)
|
|
118
|
+
browser_opened = open_authorization_url(verification_uri)
|
|
119
|
+
if browser_opened:
|
|
120
|
+
console.print(f"Opened browser: [link]{verification_uri}[/link]")
|
|
121
|
+
else:
|
|
122
|
+
console.print(f"Please visit: [link]{verification_uri}[/link]")
|
|
123
|
+
if not device_data.get("verification_uri_complete"):
|
|
124
|
+
console.print(
|
|
125
|
+
f"And enter code: [bold green]{device_data['user_code']}[/bold green]"
|
|
126
|
+
)
|
|
127
|
+
console.print("\nWaiting for authorization...")
|
|
128
|
+
|
|
129
|
+
# Poll for token
|
|
130
|
+
interval = device_data.get("interval", 5)
|
|
131
|
+
expires_in = device_data.get("expires_in", 1800)
|
|
132
|
+
start_time = time.time()
|
|
133
|
+
|
|
134
|
+
while time.time() - start_time < expires_in:
|
|
135
|
+
await asyncio.sleep(interval)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
token_response = await client.post(
|
|
139
|
+
endpoints["token_endpoint"],
|
|
140
|
+
data={
|
|
141
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
142
|
+
"device_code": device_data["device_code"],
|
|
143
|
+
"client_id": client_id,
|
|
144
|
+
"code_verifier": code_verifier,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if token_response.status_code == 200:
|
|
149
|
+
token_data = token_response.json()
|
|
150
|
+
if not isinstance(token_data, dict):
|
|
151
|
+
raise AuthError("OIDC token response is not a JSON object")
|
|
152
|
+
return cast(Dict[str, Any], token_data)
|
|
153
|
+
elif token_response.status_code == 400:
|
|
154
|
+
error_data = token_response.json()
|
|
155
|
+
error = error_data.get("error", "unknown_error")
|
|
156
|
+
|
|
157
|
+
if error == "authorization_pending":
|
|
158
|
+
continue
|
|
159
|
+
elif error == "slow_down":
|
|
160
|
+
interval += 5
|
|
161
|
+
continue
|
|
162
|
+
elif error == "expired_token":
|
|
163
|
+
raise AuthError("Device code expired")
|
|
164
|
+
else:
|
|
165
|
+
raise AuthError(f"Authorization failed: {error}")
|
|
166
|
+
|
|
167
|
+
except httpx.HTTPError as e:
|
|
168
|
+
raise AuthError(f"Failed to poll for token: {e}")
|
|
169
|
+
|
|
170
|
+
raise AuthError("Authorization timed out")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def refresh_access_token(
|
|
174
|
+
issuer: str, client_id: str, refresh_token: str
|
|
175
|
+
) -> Dict[str, Any]:
|
|
176
|
+
"""Refresh the access token using the stored refresh token."""
|
|
177
|
+
endpoints = await discover_oidc_endpoints(issuer)
|
|
178
|
+
async with httpx.AsyncClient() as client:
|
|
179
|
+
try:
|
|
180
|
+
token_response = await client.post(
|
|
181
|
+
endpoints["token_endpoint"],
|
|
182
|
+
data={
|
|
183
|
+
"grant_type": "refresh_token",
|
|
184
|
+
"client_id": client_id,
|
|
185
|
+
"refresh_token": refresh_token,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
except httpx.HTTPError as e:
|
|
189
|
+
raise AuthError(f"Failed to refresh access token: {e}") from e
|
|
190
|
+
|
|
191
|
+
if token_response.status_code == 200:
|
|
192
|
+
token_data = token_response.json()
|
|
193
|
+
if not isinstance(token_data, dict):
|
|
194
|
+
raise AuthError("OIDC refresh response is not a JSON object")
|
|
195
|
+
return cast(Dict[str, Any], token_data)
|
|
196
|
+
|
|
197
|
+
if token_response.status_code == 400:
|
|
198
|
+
error = token_response.json().get("error")
|
|
199
|
+
if error in {"invalid_grant", "invalid_request"}:
|
|
200
|
+
raise AccessTokenExpired(ACCESS_TOKEN_EXPIRED_MESSAGE)
|
|
201
|
+
|
|
202
|
+
raise AuthError(
|
|
203
|
+
f"Failed to refresh access token: HTTP {token_response.status_code}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_user_info(token: str) -> Optional[Dict[str, Any]]:
|
|
208
|
+
"""Extract unverified display information from a JWT token."""
|
|
209
|
+
try:
|
|
210
|
+
payload = jwt.decode(token, options={"verify_signature": False})
|
|
211
|
+
return {
|
|
212
|
+
"sub": payload.get("sub"),
|
|
213
|
+
"email": payload.get("email"),
|
|
214
|
+
"name": payload.get("name"),
|
|
215
|
+
"preferred_username": payload.get("preferred_username"),
|
|
216
|
+
"exp": payload.get("exp"),
|
|
217
|
+
}
|
|
218
|
+
except jwt.InvalidTokenError:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def is_token_expired(token: str) -> bool:
|
|
223
|
+
"""Check the current access token's own exp claim.
|
|
224
|
+
|
|
225
|
+
The exp value is used only as a local refresh hint. Identity data is not
|
|
226
|
+
trusted from the unsigned local decode.
|
|
227
|
+
"""
|
|
228
|
+
claims = get_user_info(token)
|
|
229
|
+
exp = claims.get("exp") if claims else None
|
|
230
|
+
if exp is None:
|
|
231
|
+
return False
|
|
232
|
+
return time.time() >= float(exp)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def fetch_user_info(issuer: str, token: str) -> Dict[str, Any]:
|
|
236
|
+
"""Fetch verified user information from the OIDC userinfo endpoint."""
|
|
237
|
+
endpoints = await discover_oidc_endpoints(issuer)
|
|
238
|
+
userinfo_endpoint = endpoints.get("userinfo_endpoint")
|
|
239
|
+
if not userinfo_endpoint:
|
|
240
|
+
raise AuthError("OIDC provider does not expose a userinfo endpoint.")
|
|
241
|
+
|
|
242
|
+
async with httpx.AsyncClient() as client:
|
|
243
|
+
try:
|
|
244
|
+
response = await client.get(
|
|
245
|
+
userinfo_endpoint,
|
|
246
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
247
|
+
)
|
|
248
|
+
except httpx.HTTPError as exc:
|
|
249
|
+
raise AuthError(f"Failed to fetch user information: {exc}") from exc
|
|
250
|
+
|
|
251
|
+
if response.status_code == 401:
|
|
252
|
+
raise AccessTokenExpired(ACCESS_TOKEN_EXPIRED_MESSAGE)
|
|
253
|
+
if response.status_code >= 400:
|
|
254
|
+
raise AuthError(
|
|
255
|
+
f"Failed to fetch user information: HTTP {response.status_code}"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
payload = response.json()
|
|
259
|
+
if not isinstance(payload, dict):
|
|
260
|
+
raise AuthError("OIDC userinfo response is not a JSON object")
|
|
261
|
+
return cast(Dict[str, Any], payload)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def persist_refreshed_token(config: Any, token_data: Dict[str, Any]) -> str:
|
|
265
|
+
"""Persist a refreshed token response and return the new access token."""
|
|
266
|
+
refreshed_token = token_data.get("access_token")
|
|
267
|
+
if not isinstance(refreshed_token, str) or refreshed_token == "":
|
|
268
|
+
raise AuthError("OIDC refresh response did not include an access token.")
|
|
269
|
+
|
|
270
|
+
refreshed_refresh_token = token_data.get("refresh_token")
|
|
271
|
+
config_manager.set_token(
|
|
272
|
+
refreshed_token,
|
|
273
|
+
(
|
|
274
|
+
refreshed_refresh_token
|
|
275
|
+
if isinstance(refreshed_refresh_token, str)
|
|
276
|
+
else config.refresh_token
|
|
277
|
+
),
|
|
278
|
+
None,
|
|
279
|
+
)
|
|
280
|
+
return refreshed_token
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def refresh_current_session() -> str:
|
|
284
|
+
"""Refresh and persist the current CLI session access token."""
|
|
285
|
+
config = config_manager.load_config()
|
|
286
|
+
if not config.refresh_token:
|
|
287
|
+
raise AccessTokenExpired(ACCESS_TOKEN_EXPIRED_MESSAGE)
|
|
288
|
+
|
|
289
|
+
token_data = asyncio.run(
|
|
290
|
+
refresh_access_token(
|
|
291
|
+
config.issuer or DEFAULT_ISSUER,
|
|
292
|
+
config.client_id or DEFAULT_CLIENT_ID,
|
|
293
|
+
config.refresh_token,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return persist_refreshed_token(config, token_data)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def async_refresh_current_session() -> str:
|
|
300
|
+
"""Async variant of refresh_current_session for request paths in an event loop."""
|
|
301
|
+
config = config_manager.load_config()
|
|
302
|
+
if not config.refresh_token:
|
|
303
|
+
raise AccessTokenExpired(ACCESS_TOKEN_EXPIRED_MESSAGE)
|
|
304
|
+
|
|
305
|
+
token_data = await refresh_access_token(
|
|
306
|
+
config.issuer or DEFAULT_ISSUER,
|
|
307
|
+
config.client_id or DEFAULT_CLIENT_ID,
|
|
308
|
+
config.refresh_token,
|
|
309
|
+
)
|
|
310
|
+
return persist_refreshed_token(config, token_data)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def require_access_token() -> str:
|
|
314
|
+
"""Return a valid access token, refreshing it when possible."""
|
|
315
|
+
config = config_manager.load_config()
|
|
316
|
+
token = config.access_token
|
|
317
|
+
if not token:
|
|
318
|
+
raise AuthError(NOT_AUTHENTICATED_MESSAGE)
|
|
319
|
+
if is_token_expired(token):
|
|
320
|
+
return refresh_current_session()
|
|
321
|
+
|
|
322
|
+
return token
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def async_require_access_token() -> str:
|
|
326
|
+
"""Async variant of require_access_token for API calls inside an event loop."""
|
|
327
|
+
config = config_manager.load_config()
|
|
328
|
+
token = config.access_token
|
|
329
|
+
if not token:
|
|
330
|
+
raise AuthError(NOT_AUTHENTICATED_MESSAGE)
|
|
331
|
+
if is_token_expired(token):
|
|
332
|
+
return await async_refresh_current_session()
|
|
333
|
+
return token
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def login(
|
|
337
|
+
local: Annotated[
|
|
338
|
+
bool,
|
|
339
|
+
typer.Option(
|
|
340
|
+
"--local",
|
|
341
|
+
help="Use the local LightNow stack at auth.lightnow.local.",
|
|
342
|
+
),
|
|
343
|
+
] = False,
|
|
344
|
+
issuer: Annotated[
|
|
345
|
+
Optional[str],
|
|
346
|
+
typer.Option(
|
|
347
|
+
"--issuer",
|
|
348
|
+
help="Override the LightNow OIDC issuer. Intended for development only.",
|
|
349
|
+
hidden=True,
|
|
350
|
+
),
|
|
351
|
+
] = None,
|
|
352
|
+
client_id: Annotated[
|
|
353
|
+
Optional[str],
|
|
354
|
+
typer.Option(
|
|
355
|
+
"--client-id",
|
|
356
|
+
help="Override the LightNow OIDC client. Intended for development only.",
|
|
357
|
+
hidden=True,
|
|
358
|
+
),
|
|
359
|
+
] = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Authenticate with LightNow."""
|
|
362
|
+
try:
|
|
363
|
+
resolved_issuer = issuer or (LOCAL_ISSUER if local else DEFAULT_ISSUER)
|
|
364
|
+
resolved_client_id = client_id or DEFAULT_CLIENT_ID
|
|
365
|
+
resolved_registry_api_url = LOCAL_REGISTRY_API_URL if local else None
|
|
366
|
+
|
|
367
|
+
# Store auth config
|
|
368
|
+
config_manager.set_auth_config(
|
|
369
|
+
resolved_issuer,
|
|
370
|
+
resolved_client_id,
|
|
371
|
+
resolved_registry_api_url,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Perform device code flow
|
|
375
|
+
if local:
|
|
376
|
+
console.print(
|
|
377
|
+
"[bold blue]Starting local LightNow authentication...[/bold blue]"
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
console.print("[bold blue]Starting LightNow authentication...[/bold blue]")
|
|
381
|
+
token_data = asyncio.run(device_code_flow(resolved_issuer, resolved_client_id))
|
|
382
|
+
token = token_data.get("access_token")
|
|
383
|
+
if not isinstance(token, str) or token == "":
|
|
384
|
+
raise AuthError("OIDC token response did not include an access token.")
|
|
385
|
+
refresh_token = token_data.get("refresh_token")
|
|
386
|
+
|
|
387
|
+
# Fetch user info from the issuer instead of trusting local JWT claims.
|
|
388
|
+
user_info = asyncio.run(fetch_user_info(resolved_issuer, token))
|
|
389
|
+
|
|
390
|
+
# Store token and user info
|
|
391
|
+
config_manager.set_token(
|
|
392
|
+
token,
|
|
393
|
+
refresh_token if isinstance(refresh_token, str) else None,
|
|
394
|
+
None,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
console.print("[bold green]✓ Authentication successful![/bold green]")
|
|
398
|
+
if user_info:
|
|
399
|
+
name = (
|
|
400
|
+
user_info.get("name")
|
|
401
|
+
or user_info.get("preferred_username")
|
|
402
|
+
or user_info.get("email")
|
|
403
|
+
)
|
|
404
|
+
if name:
|
|
405
|
+
console.print(f"Logged in as: [bold]{name}[/bold]")
|
|
406
|
+
|
|
407
|
+
except AuthError as e:
|
|
408
|
+
console.print(f"[bold red]Authentication failed:[/bold red] {e}")
|
|
409
|
+
raise typer.Exit(1)
|
|
410
|
+
except Exception as e:
|
|
411
|
+
console.print(f"[bold red]Unexpected error:[/bold red] {e}")
|
|
412
|
+
raise typer.Exit(1)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def status() -> None:
|
|
416
|
+
"""Show current authentication status."""
|
|
417
|
+
user_info = current_user_info()
|
|
418
|
+
|
|
419
|
+
console.print("[bold green]Authenticated with LightNow[/bold green]")
|
|
420
|
+
name = (
|
|
421
|
+
user_info.get("name")
|
|
422
|
+
or user_info.get("preferred_username")
|
|
423
|
+
or user_info.get("email")
|
|
424
|
+
)
|
|
425
|
+
if name:
|
|
426
|
+
console.print(f"Account: [bold]{name}[/bold]")
|
|
427
|
+
|
|
428
|
+
email = user_info.get("email")
|
|
429
|
+
if email:
|
|
430
|
+
console.print(f"Email: [bold]{email}[/bold]")
|
|
431
|
+
|
|
432
|
+
exp = user_info.get("exp")
|
|
433
|
+
if exp:
|
|
434
|
+
exp_time = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(exp))
|
|
435
|
+
console.print(f"Token expires: [dim]{exp_time}[/dim]")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def whoami() -> None:
|
|
439
|
+
"""Show current user information."""
|
|
440
|
+
user_info = current_user_info()
|
|
441
|
+
|
|
442
|
+
# Display user information
|
|
443
|
+
console.print("[bold blue]Current User Information:[/bold blue]")
|
|
444
|
+
|
|
445
|
+
name = user_info.get("name")
|
|
446
|
+
if name:
|
|
447
|
+
console.print(f"Name: [bold]{name}[/bold]")
|
|
448
|
+
|
|
449
|
+
email = user_info.get("email")
|
|
450
|
+
if email:
|
|
451
|
+
console.print(f"Email: [bold]{email}[/bold]")
|
|
452
|
+
|
|
453
|
+
username = user_info.get("preferred_username")
|
|
454
|
+
if username:
|
|
455
|
+
console.print(f"Username: [bold]{username}[/bold]")
|
|
456
|
+
|
|
457
|
+
sub = user_info.get("sub")
|
|
458
|
+
if sub:
|
|
459
|
+
console.print(f"Subject: [dim]{sub}[/dim]")
|
|
460
|
+
|
|
461
|
+
exp = user_info.get("exp")
|
|
462
|
+
if exp:
|
|
463
|
+
exp_time = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(exp))
|
|
464
|
+
console.print(f"Token expires: [dim]{exp_time}[/dim]")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def current_user_info() -> Dict[str, Any]:
|
|
468
|
+
"""Return current user info after verifying or refreshing the access token."""
|
|
469
|
+
try:
|
|
470
|
+
token = require_access_token()
|
|
471
|
+
except AccessTokenExpired as exc:
|
|
472
|
+
console.print(f"[red]{exc}[/red]")
|
|
473
|
+
raise typer.Exit(1) from exc
|
|
474
|
+
except AuthError as exc:
|
|
475
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
476
|
+
raise typer.Exit(1) from exc
|
|
477
|
+
|
|
478
|
+
config = config_manager.load_config()
|
|
479
|
+
try:
|
|
480
|
+
return asyncio.run(fetch_user_info(config.issuer or DEFAULT_ISSUER, token))
|
|
481
|
+
except AccessTokenExpired:
|
|
482
|
+
try:
|
|
483
|
+
token = refresh_current_session()
|
|
484
|
+
config = config_manager.load_config()
|
|
485
|
+
return asyncio.run(fetch_user_info(config.issuer or DEFAULT_ISSUER, token))
|
|
486
|
+
except AccessTokenExpired as exc:
|
|
487
|
+
console.print(f"[red]{exc}[/red]")
|
|
488
|
+
raise typer.Exit(1) from exc
|
|
489
|
+
except AuthError as exc:
|
|
490
|
+
console.print(f"[red]{exc}[/red]")
|
|
491
|
+
raise typer.Exit(1) from exc
|
|
492
|
+
except AuthError as exc:
|
|
493
|
+
console.print(f"[red]{exc}[/red]")
|
|
494
|
+
raise typer.Exit(1) from exc
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def logout() -> None:
|
|
498
|
+
"""Clear stored authentication token."""
|
|
499
|
+
config_manager.clear_token()
|
|
500
|
+
console.print("[green]✓ Logged out successfully[/green]")
|