clue2app-cli 0.2.7__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.
clue2app_cli/auth.py ADDED
@@ -0,0 +1,343 @@
1
+ """
2
+ Authentication module for Clue2App CLI.
3
+
4
+ Handles user authentication, token management, and login/logout functionality.
5
+ """
6
+
7
+ import time
8
+ import webbrowser
9
+ import secrets
10
+ import platform
11
+ import base64
12
+ import json
13
+ from datetime import datetime
14
+ from typing import Optional, Dict, Any
15
+
16
+ import click
17
+ import requests
18
+ import keyring
19
+ from rich.console import Console
20
+ from rich.prompt import Prompt, Confirm
21
+ from rich.panel import Panel
22
+ from rich.spinner import Spinner
23
+
24
+ from . import config
25
+ from .version import __version__
26
+
27
+ console = Console()
28
+
29
+ # Constants
30
+ SERVICE_NAME = "clue2app-cli"
31
+ KEYRING_USERNAME = "default"
32
+ POLLING_INTERVAL = 5 # seconds
33
+ MAX_POLLING_ATTEMPTS = 60 # 5 minutes total
34
+
35
+ # User-Agent string
36
+ USER_AGENT = f"Clue2App-CLI/{__version__} ({platform.system()} {platform.release()})"
37
+
38
+
39
+ class AuthenticationError(Exception):
40
+ """Custom exception for authentication errors."""
41
+ pass
42
+
43
+
44
+ def parse_jwt_token(token: str) -> Dict[str, Any]:
45
+ """Parse JWT token and extract payload."""
46
+ try:
47
+ # Split token into parts
48
+ parts = token.split('.')
49
+ if len(parts) != 3:
50
+ raise ValueError("Invalid JWT token format")
51
+
52
+ # Decode payload (second part)
53
+ payload = parts[1]
54
+ # Add padding if needed
55
+ payload += '=' * (4 - len(payload) % 4)
56
+ # Decode base64
57
+ decoded = base64.b64decode(payload)
58
+ # Parse JSON
59
+ return json.loads(decoded)
60
+ except Exception as e:
61
+ raise AuthenticationError(f"Failed to parse JWT token: {e}")
62
+
63
+
64
+ def save_user_info(token_data: Dict[str, Any]) -> bool:
65
+ """Save user information from token data."""
66
+ try:
67
+ creds = config.load_credentials()
68
+ creds.update({
69
+ "user_id": token_data.get("sub"),
70
+ "username": token_data.get("username"),
71
+ "email": token_data.get("email"),
72
+ "last_login": datetime.now().isoformat()
73
+ })
74
+ return config.save_credentials(creds)
75
+ except Exception:
76
+ return False
77
+
78
+
79
+ class APIClient:
80
+ """Simple API client for Clue2App authentication."""
81
+
82
+ def __init__(self):
83
+ self.auth_base_url = config.get_config_value("auth.base_url", "https://auth.control.apps.clue2.app")
84
+ self.auth_version = config.get_config_value("auth.version", "v1")
85
+ self.auth_timeout = config.get_config_value("auth.timeout", 30)
86
+ self.console_url = config.get_config_value("auth.console_url", "https://console.control.apps.clue2.app")
87
+ self.headers = {
88
+ "User-Agent": USER_AGENT,
89
+ "Accept": "application/json",
90
+ "Content-Type": "application/json"
91
+ }
92
+
93
+ def generate_auth_code(self) -> str:
94
+ """Generate a random authentication code."""
95
+ return secrets.token_urlsafe(32)
96
+
97
+ def start_browser_auth(self, code: str) -> None:
98
+ """Open browser for authentication."""
99
+ auth_url = f"{self.console_url}/cli-login?code={code}"
100
+ webbrowser.open(auth_url)
101
+
102
+ def validate_auth_code(self, code: str) -> Optional[str]:
103
+ """Check if the authentication code has been validated."""
104
+ try:
105
+ response = requests.post(
106
+ f"{self.auth_base_url}/api/auth/validate-cli-code",
107
+ json={"authCode": code},
108
+ headers=self.headers,
109
+ timeout=self.auth_timeout
110
+ )
111
+ if response.status_code == 200:
112
+ return response.text
113
+ elif response.status_code == 404:
114
+ return None # Code not yet validated
115
+ else:
116
+ response.raise_for_status()
117
+ except requests.exceptions.RequestException as e:
118
+ raise AuthenticationError(f"Code validation failed: {e}")
119
+
120
+
121
+ def get_access_token() -> Optional[str]:
122
+ """Get the current access token from file storage."""
123
+ try:
124
+ # Use file-based storage (avoids keyring password prompts)
125
+ creds = config.load_credentials()
126
+ return creds.get("access_token")
127
+ except Exception:
128
+ return None
129
+
130
+
131
+ def save_access_token(token: str) -> bool:
132
+ """Save access token to file."""
133
+ try:
134
+ # Save to credentials file (avoids keyring password prompts)
135
+ creds = config.load_credentials()
136
+ creds["access_token"] = token
137
+ return config.save_credentials(creds)
138
+ except Exception:
139
+ return False
140
+
141
+
142
+ def clear_access_token() -> bool:
143
+ """Clear stored access token."""
144
+ try:
145
+ # Clear from credentials file
146
+ creds = config.load_credentials()
147
+ if "access_token" in creds:
148
+ del creds["access_token"]
149
+ config.save_credentials(creds)
150
+ return config.clear_credentials()
151
+ except Exception:
152
+ return False
153
+
154
+
155
+ def get_current_user() -> Optional[str]:
156
+ """Get current logged-in user."""
157
+ creds = config.load_credentials()
158
+ return creds.get("username") or creds.get("email")
159
+
160
+
161
+ def is_authenticated() -> bool:
162
+ """Check if user is currently authenticated with a valid (non-expired) token."""
163
+ token = get_access_token()
164
+ if not token:
165
+ return False
166
+
167
+ # Check if token is expired
168
+ try:
169
+ token_data = parse_jwt_token(token)
170
+ exp = token_data.get("exp")
171
+ if exp:
172
+ import time
173
+ # Token is expired if exp is in the past
174
+ if exp < time.time():
175
+ return False
176
+ except:
177
+ # If we can't parse the token, treat as not authenticated
178
+ return False
179
+
180
+ return True
181
+
182
+
183
+ @click.group()
184
+ def auth_commands():
185
+ """Authentication related commands."""
186
+ pass
187
+
188
+
189
+ @auth_commands.command(name="login")
190
+ @click.option('--remember/--no-remember', default=True, help='Remember login credentials')
191
+ def login(remember: bool):
192
+ """
193
+ 🔐 Login to your Clue2App account
194
+
195
+ Authenticate with the Clue2App platform using your browser.
196
+ Your credentials will be stored securely for future use.
197
+ """
198
+ console.print("[bold blue]🔐 Clue2App Login[/bold blue]")
199
+
200
+ # Check if already logged in
201
+ if is_authenticated():
202
+ current_user = get_current_user()
203
+ if not Confirm.ask(f"Already logged in as [bold]{current_user}[/bold]. Login as different user?"):
204
+ console.print("✅ [green]Already authenticated[/green]")
205
+ return
206
+
207
+ # Create API client
208
+ client = APIClient()
209
+
210
+ try:
211
+ # Generate auth code
212
+ auth_code = client.generate_auth_code()
213
+
214
+ # Open browser for authentication
215
+ console.print("\n[yellow]Opening your browser for authentication...[/yellow]")
216
+ client.start_browser_auth(auth_code)
217
+
218
+ # Poll for authentication completion
219
+ console.print("\n[bold green]Waiting for authentication...[/bold green]")
220
+ console.print("Please complete the authentication in your browser.")
221
+
222
+ auth_result = None
223
+ attempts = 0
224
+
225
+ with console.status("[bold blue]Checking authentication status...[/bold blue]", spinner="dots"):
226
+ while attempts < MAX_POLLING_ATTEMPTS:
227
+ auth_result = client.validate_auth_code(auth_code)
228
+ if auth_result:
229
+ break
230
+
231
+ time.sleep(POLLING_INTERVAL)
232
+ attempts += 1
233
+
234
+ if not auth_result:
235
+ raise AuthenticationError("Authentication timed out. Please try again.")
236
+
237
+ # Parse JWT token and save user info
238
+ token_data = parse_jwt_token(auth_result)
239
+
240
+ # Save authentication data
241
+ if remember:
242
+ save_access_token(auth_result)
243
+ save_user_info(token_data)
244
+
245
+ console.print()
246
+ console.print("✅ [bold green]Login successful![/bold green]")
247
+
248
+ # Display user info
249
+ username = token_data.get("username") or token_data.get("email")
250
+ if username:
251
+ console.print(f" Logged in as: [bold]{username}[/bold]")
252
+
253
+ if remember:
254
+ console.print(" Credentials saved for future use")
255
+
256
+ except AuthenticationError as e:
257
+ console.print()
258
+ console.print(f"[red]❌ Authentication failed:[/red] {e}")
259
+ raise click.Abort()
260
+ except Exception as e:
261
+ console.print()
262
+ console.print(f"[red]❌ Login error:[/red] {e}")
263
+ raise click.Abort()
264
+
265
+
266
+ @auth_commands.command(name="logout")
267
+ @click.option('--force', '-f', is_flag=True, help='Force logout without confirmation')
268
+ def logout(force: bool):
269
+ """
270
+ 🚪 Logout from your Clue2App account
271
+
272
+ Sign out and clear stored credentials.
273
+ """
274
+ if not is_authenticated():
275
+ console.print("[yellow]⚠️ Not currently logged in[/yellow]")
276
+ return
277
+
278
+ current_user = get_current_user()
279
+
280
+ if not force:
281
+ if not Confirm.ask(f"Logout from account [bold]{current_user}[/bold]?"):
282
+ console.print("Logout cancelled")
283
+ return
284
+
285
+ console.print()
286
+ with console.status("[bold blue]Logging out...[/bold blue]", spinner="dots"):
287
+ try:
288
+ # Clear local credentials
289
+ clear_access_token()
290
+
291
+ console.print()
292
+ console.print("✅ [bold green]Logged out successfully[/bold green]")
293
+
294
+ except Exception as e:
295
+ console.print()
296
+ console.print(f"[yellow]⚠️ Logout completed (with warnings): {e}[/yellow]")
297
+
298
+
299
+ @auth_commands.command(name="status")
300
+ def auth_status():
301
+ """
302
+ 📊 Show authentication status
303
+
304
+ Display current login status and user information.
305
+ """
306
+ console.print("[bold blue]🔐 Authentication Status[/bold blue]")
307
+ console.print()
308
+
309
+ if is_authenticated():
310
+ current_user = get_current_user()
311
+ creds = config.load_credentials()
312
+
313
+ console.print(f"✅ [green]Authenticated[/green] as [bold]{current_user}[/bold]")
314
+
315
+ last_login = creds.get("last_login")
316
+ if last_login:
317
+ try:
318
+ login_time = datetime.fromisoformat(last_login)
319
+ console.print(f" Last login: {login_time.strftime('%Y-%m-%d %H:%M:%S')}")
320
+ except ValueError:
321
+ pass
322
+
323
+ # Show token status
324
+ if get_access_token():
325
+ console.print(" 🔑 [green]Access token[/green]: Available")
326
+ else:
327
+ console.print("❌ [red]Not authenticated[/red]")
328
+ console.print(" Run [bold green]c2a login[/bold green] to authenticate")
329
+
330
+
331
+ # Register login and logout as top-level commands too
332
+ @click.command()
333
+ @click.option('--remember/--no-remember', default=True, help='Remember login credentials')
334
+ def login_command(remember: bool):
335
+ """🔐 Login to your Clue2App account"""
336
+ login.callback(remember)
337
+
338
+
339
+ @click.command()
340
+ @click.option('--force', '-f', is_flag=True, help='Force logout without confirmation')
341
+ def logout_command(force: bool):
342
+ """🚪 Logout from your Clue2App account"""
343
+ logout.callback(force)
clue2app_cli/config.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ Configuration management for Clue2App CLI.
3
+
4
+ Handles configuration file creation, reading, and updating.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import toml
10
+ from pathlib import Path
11
+ from typing import Dict, Any, Optional
12
+
13
+ import click
14
+ from rich.console import Console
15
+
16
+ console = Console()
17
+
18
+ # Configuration file names
19
+ CONFIG_FILE = "config.toml"
20
+ CREDENTIALS_FILE = "credentials.json"
21
+
22
+
23
+ def get_config_dir() -> Path:
24
+ """Get the configuration directory for the CLI."""
25
+ if os.name == 'nt': # Windows
26
+ config_dir = Path(os.environ.get('APPDATA', '~')) / '.c2a'
27
+ else: # Unix-like systems
28
+ config_dir = Path.home() / '.c2a'
29
+
30
+ return config_dir
31
+
32
+
33
+ def ensure_config_dir() -> Path:
34
+ """Ensure the configuration directory exists and return its path."""
35
+ config_dir = get_config_dir()
36
+ config_dir.mkdir(parents=True, exist_ok=True)
37
+ return config_dir
38
+
39
+
40
+ def get_config_file() -> Path:
41
+ """Get the path to the configuration file."""
42
+ return get_config_dir() / CONFIG_FILE
43
+
44
+
45
+ def get_credentials_file() -> Path:
46
+ """Get the path to the credentials file."""
47
+ return get_config_dir() / CREDENTIALS_FILE
48
+
49
+
50
+ def load_config() -> Dict[str, Any]:
51
+ """Load configuration from file."""
52
+ config_file = get_config_file()
53
+
54
+ if not config_file.exists():
55
+ return get_default_config()
56
+
57
+ try:
58
+ with open(config_file, 'r') as f:
59
+ config = toml.load(f)
60
+ return config
61
+ except Exception as e:
62
+ console.print(f"[red]Error loading config:[/red] {e}")
63
+ return get_default_config()
64
+
65
+
66
+ def save_config(config: Dict[str, Any]) -> bool:
67
+ """Save configuration to file."""
68
+ try:
69
+ ensure_config_dir()
70
+ config_file = get_config_file()
71
+
72
+ with open(config_file, 'w') as f:
73
+ toml.dump(config, f)
74
+
75
+ return True
76
+ except Exception as e:
77
+ console.print(f"[red]Error saving config:[/red] {e}")
78
+ return False
79
+
80
+
81
+ def get_default_config() -> Dict[str, Any]:
82
+ """Get default configuration."""
83
+ return {
84
+ "api": {
85
+ "base_url": "https://coordinator.control.apps.clue2.app",
86
+ "version": "v1",
87
+ "timeout": 30
88
+ },
89
+ "auth": {
90
+ "base_url": "https://auth.control.apps.clue2.app",
91
+ "console_url": "https://console.control.apps.clue2.app",
92
+ "version": "v1",
93
+ "timeout": 30,
94
+ "auto_refresh": True,
95
+ "remember_me": True
96
+ },
97
+ "cli": {
98
+ "output_format": "table",
99
+ "color": True,
100
+ "paging": True
101
+ }
102
+ }
103
+
104
+
105
+ def get_config_value(key: str, default: Any = None) -> Any:
106
+ """Get a configuration value using dot notation (e.g., 'api.base_url')."""
107
+ config = load_config()
108
+
109
+ keys = key.split('.')
110
+ value = config
111
+
112
+ try:
113
+ for k in keys:
114
+ value = value[k]
115
+ return value
116
+ except (KeyError, TypeError):
117
+ return default
118
+
119
+
120
+ def set_config_value(key: str, value: Any) -> bool:
121
+ """Set a configuration value using dot notation."""
122
+ config = load_config()
123
+
124
+ keys = key.split('.')
125
+ current = config
126
+
127
+ # Navigate to the parent of the key we want to set
128
+ for k in keys[:-1]:
129
+ if k not in current:
130
+ current[k] = {}
131
+ current = current[k]
132
+
133
+ # Set the value
134
+ current[keys[-1]] = value
135
+
136
+ return save_config(config)
137
+
138
+
139
+ def load_credentials() -> Dict[str, Any]:
140
+ """Load stored credentials."""
141
+ creds_file = get_credentials_file()
142
+
143
+ if not creds_file.exists():
144
+ return {}
145
+
146
+ try:
147
+ with open(creds_file, 'r') as f:
148
+ return json.load(f)
149
+ except Exception as e:
150
+ console.print(f"[red]Error loading credentials:[/red] {e}")
151
+ return {}
152
+
153
+
154
+ def save_credentials(credentials: Dict[str, Any]) -> bool:
155
+ """Save credentials to file."""
156
+ try:
157
+ ensure_config_dir()
158
+ creds_file = get_credentials_file()
159
+
160
+ with open(creds_file, 'w') as f:
161
+ json.dump(credentials, f, indent=2)
162
+
163
+ # Set restrictive permissions on credentials file
164
+ os.chmod(creds_file, 0o600)
165
+
166
+ return True
167
+ except Exception as e:
168
+ console.print(f"[red]Error saving credentials:[/red] {e}")
169
+ return False
170
+
171
+
172
+ def clear_credentials() -> bool:
173
+ """Clear stored credentials."""
174
+ try:
175
+ creds_file = get_credentials_file()
176
+ if creds_file.exists():
177
+ creds_file.unlink()
178
+ return True
179
+ except Exception as e:
180
+ console.print(f"[red]Error clearing credentials:[/red] {e}")
181
+ return False
182
+
183
+
184
+ # Initialize configuration on first import
185
+ def initialize_config():
186
+ """Initialize configuration with defaults if it doesn't exist."""
187
+ config_file = get_config_file()
188
+ if not config_file.exists():
189
+ default_config = get_default_config()
190
+ save_config(default_config)
191
+
192
+
193
+ # Auto-initialize
194
+ try:
195
+ initialize_config()
196
+ except Exception:
197
+ # Silently fail if we can't initialize config
198
+ pass
199
+
200
+
201
+ # Context management for active project
202
+ def get_active_project() -> Optional[Dict[str, Any]]:
203
+ """Get the currently active/selected project."""
204
+ return get_config_value("context.project", None)
205
+
206
+
207
+ def set_active_project(project_id: str, project_name: str, namespace: str) -> bool:
208
+ """Set the active project context."""
209
+ config = load_config()
210
+ if "context" not in config:
211
+ config["context"] = {}
212
+ config["context"]["project"] = {
213
+ "id": project_id,
214
+ "name": project_name,
215
+ "namespace": namespace
216
+ }
217
+ return save_config(config)
218
+
219
+
220
+ def clear_active_project() -> bool:
221
+ """Clear the active project context."""
222
+ config = load_config()
223
+ if "context" in config and "project" in config["context"]:
224
+ del config["context"]["project"]
225
+ return save_config(config)
226
+ return True
227
+
228
+
229
+ class ConfigManager:
230
+ """
231
+ Configuration manager class for CLI commands.
232
+
233
+ Provides a class-based interface for managing CLI configuration.
234
+ """
235
+
236
+ def __init__(self):
237
+ """Initialize the ConfigManager."""
238
+ ensure_config_dir()
239
+
240
+ def get_config(self, key: str) -> Any:
241
+ """
242
+ Get a configuration value using dot notation.
243
+
244
+ Args:
245
+ key: Configuration key in dot notation (e.g., 'api.base_url')
246
+
247
+ Returns:
248
+ The configuration value, or None if not found
249
+ """
250
+ return get_config_value(key)
251
+
252
+ def set_config(self, key: str, value: Any) -> bool:
253
+ """
254
+ Set a configuration value using dot notation.
255
+
256
+ Args:
257
+ key: Configuration key in dot notation (e.g., 'api.base_url')
258
+ value: Value to set
259
+
260
+ Returns:
261
+ True if successful, False otherwise
262
+ """
263
+ # Try to convert value to appropriate type
264
+ converted_value = self._convert_value(value)
265
+ return set_config_value(key, converted_value)
266
+
267
+ def reset_config(self) -> bool:
268
+ """
269
+ Reset configuration to default values.
270
+
271
+ Returns:
272
+ True if successful, False otherwise
273
+ """
274
+ default_config = get_default_config()
275
+ return save_config(default_config)
276
+
277
+ def get_all(self) -> Dict[str, Any]:
278
+ """
279
+ Get all configuration values.
280
+
281
+ Returns:
282
+ Dictionary containing all configuration
283
+ """
284
+ return load_config()
285
+
286
+ def _convert_value(self, value: str) -> Any:
287
+ """
288
+ Convert a string value to the appropriate type.
289
+
290
+ Args:
291
+ value: String value to convert
292
+
293
+ Returns:
294
+ Converted value (int, float, bool, or string)
295
+ """
296
+ if isinstance(value, str):
297
+ # Try boolean
298
+ if value.lower() in ('true', 'yes', '1'):
299
+ return True
300
+ if value.lower() in ('false', 'no', '0'):
301
+ return False
302
+
303
+ # Try integer
304
+ try:
305
+ return int(value)
306
+ except ValueError:
307
+ pass
308
+
309
+ # Try float
310
+ try:
311
+ return float(value)
312
+ except ValueError:
313
+ pass
314
+
315
+ # Return as-is
316
+ return value
@@ -0,0 +1,46 @@
1
+ """Thin HTTP client for talking to the platform's `core` service.
2
+
3
+ Lets the CLI call core's REST endpoints (creds/scan, services/health,
4
+ images/safety-drift, code/audit, etc.) without needing kubectl on the
5
+ caller's machine. Core does the privileged cluster I/O on the user's
6
+ behalf.
7
+
8
+ URL resolution:
9
+ 1. `CORE_SERVICE_URL` env var (overrides everything; for staging/dev)
10
+ 2. The default public ingress URL (production)
11
+
12
+ There's no auth header today because core's read endpoints are open on
13
+ the ingress — same as how the agents call them. Add bearer-token
14
+ support here when core grows authn.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import Any, Optional
20
+
21
+ import requests
22
+
23
+
24
+ DEFAULT_CORE_URL = "https://core.control.apps.clue2.app"
25
+
26
+
27
+ def core_base_url():
28
+ return os.environ.get("CORE_SERVICE_URL", DEFAULT_CORE_URL).rstrip("/")
29
+
30
+
31
+ def core_post(path, body=None, timeout=60.0):
32
+ """POST JSON to a core endpoint. Returns the decoded response.
33
+ Raises requests.HTTPError on transport failures or 5xx; 4xx
34
+ surfaces as a response with the error in the body."""
35
+ url = core_base_url() + path
36
+ resp = requests.post(url, json=body or {}, timeout=timeout)
37
+ resp.raise_for_status()
38
+ return resp.json()
39
+
40
+
41
+ def core_get(path, params=None, timeout=30.0):
42
+ """GET from a core endpoint. Returns the decoded response."""
43
+ url = core_base_url() + path
44
+ resp = requests.get(url, params=params or {}, timeout=timeout)
45
+ resp.raise_for_status()
46
+ return resp.json()