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.
@@ -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]")