xenfra 0.2.9__py3-none-any.whl → 0.3.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.
@@ -1,3 +1,3 @@
1
- """
2
- CLI command modules for Xenfra.
3
- """
1
+ """
2
+ CLI command modules for Xenfra.
3
+ """
xenfra/commands/auth.py CHANGED
@@ -1,137 +1,137 @@
1
- """
2
- Authentication commands for Xenfra CLI.
3
- """
4
-
5
- import base64
6
- import hashlib
7
- import secrets
8
- import urllib.parse
9
- import webbrowser
10
- from http.server import HTTPServer
11
-
12
- import click
13
- import httpx
14
- import keyring
15
- from rich.console import Console
16
- from tenacity import (
17
- retry,
18
- retry_if_exception_type,
19
- stop_after_attempt,
20
- wait_exponential,
21
- )
22
-
23
- from ..utils.auth import (
24
- API_BASE_URL,
25
- CLI_CLIENT_ID,
26
- CLI_LOCAL_SERVER_END_PORT,
27
- CLI_LOCAL_SERVER_START_PORT,
28
- CLI_REDIRECT_PATH,
29
- SERVICE_ID,
30
- AuthCallbackHandler,
31
- clear_tokens,
32
- get_auth_token,
33
- )
34
-
35
- console = Console()
36
-
37
- # HTTP request timeout (30 seconds)
38
- HTTP_TIMEOUT = 30.0
39
-
40
-
41
- @click.group()
42
- def auth():
43
- """Authentication commands (login, logout, whoami)."""
44
- pass
45
-
46
-
47
- @retry(
48
- stop=stop_after_attempt(3),
49
- wait=wait_exponential(multiplier=1, min=2, max=10),
50
- retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
51
- reraise=True,
52
- )
53
- def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect_uri: str) -> dict:
54
- """
55
- Exchange authorization code for tokens with retry logic.
56
-
57
- Returns token data dictionary.
58
- """
59
- with httpx.Client(timeout=HTTP_TIMEOUT) as client:
60
- response = client.post(
61
- f"{API_BASE_URL}/auth/token",
62
- data={
63
- "grant_type": "authorization_code",
64
- "client_id": CLI_CLIENT_ID,
65
- "code": code,
66
- "code_verifier": code_verifier,
67
- "redirect_uri": redirect_uri,
68
- },
69
- headers={"Accept": "application/json"},
70
- )
71
- response.raise_for_status()
72
-
73
- # Safe JSON parsing with content-type check
74
- content_type = response.headers.get("content-type", "")
75
- if "application/json" not in content_type:
76
- raise ValueError(f"Expected JSON response, got {content_type}")
77
-
78
- try:
79
- token_data = response.json()
80
- except (ValueError, TypeError) as e:
81
- raise ValueError(f"Failed to parse JSON response: {e}")
82
-
83
- return token_data
84
-
85
-
86
- @auth.command()
87
- def login():
88
- """Login to Xenfra using Device Authorization Flow (like GitHub CLI, Claude Code)."""
89
- from .auth_device import device_login
90
- device_login()
91
-
92
- # Removed old PKCE flow - now using Device Authorization Flow
93
-
94
-
95
- @auth.command()
96
- def logout():
97
- """Logout and clear stored tokens."""
98
- try:
99
- clear_tokens()
100
- console.print("[bold green]Logged out successfully.[/bold green]")
101
- except Exception as e:
102
- console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
103
- console.print("[dim]Tokens may still be stored in keyring.[/dim]")
104
-
105
-
106
- @auth.command()
107
- @click.option("--token", is_flag=True, help="Show access token")
108
- def whoami(token):
109
- """Show current authenticated user."""
110
- access_token = get_auth_token()
111
-
112
- if not access_token:
113
- console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
114
- return
115
-
116
- try:
117
- from jose import jwt
118
-
119
- # For display purposes only, in a CLI context where the token has just
120
- # been retrieved from a secure source (keyring), we can disable
121
- # signature verification.
122
- #
123
- # SECURITY BEST PRACTICE: In a real application, especially a server,
124
- # you would fetch the public key from the SSO's JWKS endpoint and
125
- # fully verify the token's signature to ensure its integrity.
126
- claims = jwt.decode(
127
- access_token, options={"verify_signature": False} # OK for local display
128
- )
129
-
130
- console.print("[bold green]Logged in as:[/bold green]")
131
- console.print(f" User ID: {claims.get('sub')}")
132
- console.print(f" Email: {claims.get('email', 'N/A')}")
133
-
134
- if token:
135
- console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
136
- except Exception as e:
137
- console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
1
+ """
2
+ Authentication commands for Xenfra CLI.
3
+ """
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+ import urllib.parse
9
+ import webbrowser
10
+ from http.server import HTTPServer
11
+
12
+ import click
13
+ import httpx
14
+ import keyring
15
+ from rich.console import Console
16
+ from tenacity import (
17
+ retry,
18
+ retry_if_exception_type,
19
+ stop_after_attempt,
20
+ wait_exponential,
21
+ )
22
+
23
+ from ..utils.auth import (
24
+ API_BASE_URL,
25
+ CLI_CLIENT_ID,
26
+ CLI_LOCAL_SERVER_END_PORT,
27
+ CLI_LOCAL_SERVER_START_PORT,
28
+ CLI_REDIRECT_PATH,
29
+ SERVICE_ID,
30
+ AuthCallbackHandler,
31
+ clear_tokens,
32
+ get_auth_token,
33
+ )
34
+
35
+ console = Console()
36
+
37
+ # HTTP request timeout (30 seconds)
38
+ HTTP_TIMEOUT = 30.0
39
+
40
+
41
+ @click.group()
42
+ def auth():
43
+ """Authentication commands (login, logout, whoami)."""
44
+ pass
45
+
46
+
47
+ @retry(
48
+ stop=stop_after_attempt(3),
49
+ wait=wait_exponential(multiplier=1, min=2, max=10),
50
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
51
+ reraise=True,
52
+ )
53
+ def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect_uri: str) -> dict:
54
+ """
55
+ Exchange authorization code for tokens with retry logic.
56
+
57
+ Returns token data dictionary.
58
+ """
59
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
60
+ response = client.post(
61
+ f"{API_BASE_URL}/auth/token",
62
+ data={
63
+ "grant_type": "authorization_code",
64
+ "client_id": CLI_CLIENT_ID,
65
+ "code": code,
66
+ "code_verifier": code_verifier,
67
+ "redirect_uri": redirect_uri,
68
+ },
69
+ headers={"Accept": "application/json"},
70
+ )
71
+ response.raise_for_status()
72
+
73
+ # Safe JSON parsing with content-type check
74
+ content_type = response.headers.get("content-type", "")
75
+ if "application/json" not in content_type:
76
+ raise ValueError(f"Expected JSON response, got {content_type}")
77
+
78
+ try:
79
+ token_data = response.json()
80
+ except (ValueError, TypeError) as e:
81
+ raise ValueError(f"Failed to parse JSON response: {e}")
82
+
83
+ return token_data
84
+
85
+
86
+ @auth.command()
87
+ def login():
88
+ """Login to Xenfra using Device Authorization Flow (like GitHub CLI, Claude Code)."""
89
+ from .auth_device import device_login
90
+ device_login()
91
+
92
+ # Removed old PKCE flow - now using Device Authorization Flow
93
+
94
+
95
+ @auth.command()
96
+ def logout():
97
+ """Logout and clear stored tokens."""
98
+ try:
99
+ clear_tokens()
100
+ console.print("[bold green]Logged out successfully.[/bold green]")
101
+ except Exception as e:
102
+ console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
103
+ console.print("[dim]Tokens may still be stored in keyring.[/dim]")
104
+
105
+
106
+ @auth.command()
107
+ @click.option("--token", is_flag=True, help="Show access token")
108
+ def whoami(token):
109
+ """Show current authenticated user."""
110
+ access_token = get_auth_token()
111
+
112
+ if not access_token:
113
+ console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
114
+ return
115
+
116
+ try:
117
+ from jose import jwt
118
+
119
+ # For display purposes only, in a CLI context where the token has just
120
+ # been retrieved from a secure source (keyring), we can disable
121
+ # signature verification.
122
+ #
123
+ # SECURITY BEST PRACTICE: In a real application, especially a server,
124
+ # you would fetch the public key from the SSO's JWKS endpoint and
125
+ # fully verify the token's signature to ensure its integrity.
126
+ claims = jwt.decode(
127
+ access_token, options={"verify_signature": False} # OK for local display
128
+ )
129
+
130
+ console.print("[bold green]Logged in as:[/bold green]")
131
+ console.print(f" User ID: {claims.get('sub')}")
132
+ console.print(f" Email: {claims.get('email', 'N/A')}")
133
+
134
+ if token:
135
+ console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
136
+ except Exception as e:
137
+ console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
@@ -1,159 +1,159 @@
1
- """
2
- Device Authorization Flow for Xenfra CLI.
3
- Modern OAuth flow used by GitHub CLI, AWS CLI, Claude Code, etc.
4
- """
5
-
6
- import time
7
- import webbrowser
8
- from urllib.parse import urlencode
9
-
10
- import click
11
- import httpx
12
- import keyring
13
- from rich.console import Console
14
- from rich.panel import Panel
15
-
16
- from ..utils.auth import API_BASE_URL, CLI_CLIENT_ID, HTTP_TIMEOUT, SERVICE_ID
17
-
18
- console = Console()
19
-
20
-
21
- def device_login():
22
- """
23
- Device Authorization Flow (OAuth 2.0 Device Grant).
24
-
25
- Flow:
26
- 1. CLI calls /auth/device/authorize to get device_code and user_code
27
- 2. User visits https://www.xenfra.tech/activate and enters user_code
28
- 3. CLI polls /auth/device/token until user authorizes
29
- 4. CLI receives access_token and stores it
30
- """
31
- try:
32
- # Step 1: Request device code
33
- console.print("[cyan]Initiating device authorization...[/cyan]")
34
-
35
- with httpx.Client(timeout=HTTP_TIMEOUT) as client:
36
- response = client.post(
37
- f"{API_BASE_URL}/auth/device/authorize",
38
- data={
39
- "client_id": CLI_CLIENT_ID,
40
- "scope": "openid profile",
41
- },
42
- )
43
- response.raise_for_status()
44
- device_data = response.json()
45
-
46
- device_code = device_data["device_code"]
47
- user_code = device_data["user_code"]
48
- verification_uri = device_data["verification_uri"]
49
- verification_uri_complete = device_data.get("verification_uri_complete")
50
- expires_in = device_data["expires_in"]
51
- interval = device_data.get("interval", 5)
52
-
53
- # Step 2: Show user code and open browser
54
- console.print()
55
- console.print(
56
- Panel.fit(
57
- f"[bold white]{user_code}[/bold white]",
58
- title="[bold green]Your Activation Code[/bold green]",
59
- border_style="green",
60
- )
61
- )
62
- console.print()
63
- console.print(f"[bold]Visit:[/bold] [link]{verification_uri}[/link]")
64
- console.print(f"[bold]Enter code:[/bold] [cyan]{user_code}[/cyan]")
65
- console.print()
66
-
67
- # Open browser automatically
68
- try:
69
- url_to_open = verification_uri_complete or verification_uri
70
- webbrowser.open(url_to_open)
71
- console.print("[dim]Opening browser...[/dim]")
72
- except Exception:
73
- console.print("[yellow]Could not open browser automatically. Please visit the URL above.[/yellow]")
74
-
75
- # Step 3: Poll for authorization
76
- console.print()
77
- console.print("[cyan]Waiting for authorization...[/cyan]")
78
- console.print("[dim](Press Ctrl+C to cancel)[/dim]")
79
- console.print()
80
-
81
- start_time = time.time()
82
- poll_count = 0
83
-
84
- with httpx.Client(timeout=HTTP_TIMEOUT) as client:
85
- while True:
86
- # Check timeout
87
- if time.time() - start_time > expires_in:
88
- console.print("[bold red]✗ Authorization timed out. Please try again.[/bold red]")
89
- return False
90
-
91
- # Poll the token endpoint
92
- try:
93
- response = client.post(
94
- f"{API_BASE_URL}/auth/device/token",
95
- data={
96
- "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
97
- "device_code": device_code,
98
- "client_id": CLI_CLIENT_ID,
99
- },
100
- )
101
-
102
- if response.status_code == 200:
103
- # Success! User authorized
104
- token_data = response.json()
105
- access_token = token_data["access_token"]
106
- refresh_token = token_data.get("refresh_token")
107
-
108
- # Store tokens in keyring
109
- try:
110
- keyring.set_password(SERVICE_ID, "access_token", access_token)
111
- if refresh_token:
112
- keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
113
- except keyring.errors.KeyringError as e:
114
- console.print(f"[yellow]Warning: Could not save tokens to keyring: {e}[/yellow]")
115
-
116
- console.print()
117
- console.print("[bold green]✓ Successfully authenticated![/bold green]")
118
- console.print()
119
- return True
120
-
121
- elif response.status_code == 400:
122
- error_data = response.json()
123
- error = error_data.get("error", "unknown_error")
124
-
125
- if error == "authorization_pending":
126
- # Still waiting for user to authorize
127
- poll_count += 1
128
- if poll_count % 6 == 0: # Every 30 seconds
129
- console.print("[dim]Still waiting...[/dim]")
130
- time.sleep(interval)
131
- continue
132
-
133
- elif error == "slow_down":
134
- # We're polling too fast
135
- interval += 5
136
- time.sleep(interval)
137
- continue
138
-
139
- else:
140
- # Other error
141
- error_desc = error_data.get("error_description", error)
142
- console.print(f"[bold red]✗ Authorization failed: {error_desc}[/bold red]")
143
- return False
144
-
145
- else:
146
- console.print(f"[bold red]✗ Unexpected response: {response.status_code}[/bold red]")
147
- return False
148
-
149
- except httpx.HTTPError as e:
150
- console.print(f"[bold red]✗ Network error: {e}[/bold red]")
151
- return False
152
-
153
- except KeyboardInterrupt:
154
- console.print()
155
- console.print("[yellow]Authorization cancelled.[/yellow]")
156
- return False
157
- except Exception as e:
158
- console.print(f"[bold red]✗ Error: {e}[/bold red]")
159
- return False
1
+ """
2
+ Device Authorization Flow for Xenfra CLI.
3
+ Modern OAuth flow used by GitHub CLI, AWS CLI, Claude Code, etc.
4
+ """
5
+
6
+ import time
7
+ import webbrowser
8
+ from urllib.parse import urlencode
9
+
10
+ import click
11
+ import httpx
12
+ import keyring
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+
16
+ from ..utils.auth import API_BASE_URL, CLI_CLIENT_ID, HTTP_TIMEOUT, SERVICE_ID
17
+
18
+ console = Console()
19
+
20
+
21
+ def device_login():
22
+ """
23
+ Device Authorization Flow (OAuth 2.0 Device Grant).
24
+
25
+ Flow:
26
+ 1. CLI calls /auth/device/authorize to get device_code and user_code
27
+ 2. User visits https://www.xenfra.tech/activate and enters user_code
28
+ 3. CLI polls /auth/device/token until user authorizes
29
+ 4. CLI receives access_token and stores it
30
+ """
31
+ try:
32
+ # Step 1: Request device code
33
+ console.print("[cyan]Initiating device authorization...[/cyan]")
34
+
35
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
36
+ response = client.post(
37
+ f"{API_BASE_URL}/auth/device/authorize",
38
+ data={
39
+ "client_id": CLI_CLIENT_ID,
40
+ "scope": "openid profile",
41
+ },
42
+ )
43
+ response.raise_for_status()
44
+ device_data = response.json()
45
+
46
+ device_code = device_data["device_code"]
47
+ user_code = device_data["user_code"]
48
+ verification_uri = device_data["verification_uri"]
49
+ verification_uri_complete = device_data.get("verification_uri_complete")
50
+ expires_in = device_data["expires_in"]
51
+ interval = device_data.get("interval", 5)
52
+
53
+ # Step 2: Show user code and open browser
54
+ console.print()
55
+ console.print(
56
+ Panel.fit(
57
+ f"[bold white]{user_code}[/bold white]",
58
+ title="[bold green]Your Activation Code[/bold green]",
59
+ border_style="green",
60
+ )
61
+ )
62
+ console.print()
63
+ console.print(f"[bold]Visit:[/bold] [link]{verification_uri}[/link]")
64
+ console.print(f"[bold]Enter code:[/bold] [cyan]{user_code}[/cyan]")
65
+ console.print()
66
+
67
+ # Open browser automatically
68
+ try:
69
+ url_to_open = verification_uri_complete or verification_uri
70
+ webbrowser.open(url_to_open)
71
+ console.print("[dim]Opening browser...[/dim]")
72
+ except Exception:
73
+ console.print("[yellow]Could not open browser automatically. Please visit the URL above.[/yellow]")
74
+
75
+ # Step 3: Poll for authorization
76
+ console.print()
77
+ console.print("[cyan]Waiting for authorization...[/cyan]")
78
+ console.print("[dim](Press Ctrl+C to cancel)[/dim]")
79
+ console.print()
80
+
81
+ start_time = time.time()
82
+ poll_count = 0
83
+
84
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
85
+ while True:
86
+ # Check timeout
87
+ if time.time() - start_time > expires_in:
88
+ console.print("[bold red]✗ Authorization timed out. Please try again.[/bold red]")
89
+ return False
90
+
91
+ # Poll the token endpoint
92
+ try:
93
+ response = client.post(
94
+ f"{API_BASE_URL}/auth/device/token",
95
+ data={
96
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
97
+ "device_code": device_code,
98
+ "client_id": CLI_CLIENT_ID,
99
+ },
100
+ )
101
+
102
+ if response.status_code == 200:
103
+ # Success! User authorized
104
+ token_data = response.json()
105
+ access_token = token_data["access_token"]
106
+ refresh_token = token_data.get("refresh_token")
107
+
108
+ # Store tokens in keyring
109
+ try:
110
+ keyring.set_password(SERVICE_ID, "access_token", access_token)
111
+ if refresh_token:
112
+ keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
113
+ except keyring.errors.KeyringError as e:
114
+ console.print(f"[yellow]Warning: Could not save tokens to keyring: {e}[/yellow]")
115
+
116
+ console.print()
117
+ console.print("[bold green]✓ Successfully authenticated![/bold green]")
118
+ console.print()
119
+ return True
120
+
121
+ elif response.status_code == 400:
122
+ error_data = response.json()
123
+ error = error_data.get("error", "unknown_error")
124
+
125
+ if error == "authorization_pending":
126
+ # Still waiting for user to authorize
127
+ poll_count += 1
128
+ if poll_count % 6 == 0: # Every 30 seconds
129
+ console.print("[dim]Still waiting...[/dim]")
130
+ time.sleep(interval)
131
+ continue
132
+
133
+ elif error == "slow_down":
134
+ # We're polling too fast
135
+ interval += 5
136
+ time.sleep(interval)
137
+ continue
138
+
139
+ else:
140
+ # Other error
141
+ error_desc = error_data.get("error_description", error)
142
+ console.print(f"[bold red]✗ Authorization failed: {error_desc}[/bold red]")
143
+ return False
144
+
145
+ else:
146
+ console.print(f"[bold red]✗ Unexpected response: {response.status_code}[/bold red]")
147
+ return False
148
+
149
+ except httpx.HTTPError as e:
150
+ console.print(f"[bold red]✗ Network error: {e}[/bold red]")
151
+ return False
152
+
153
+ except KeyboardInterrupt:
154
+ console.print()
155
+ console.print("[yellow]Authorization cancelled.[/yellow]")
156
+ return False
157
+ except Exception as e:
158
+ console.print(f"[bold red]✗ Error: {e}[/bold red]")
159
+ return False