Skip to content

Commit b21a9ba

Browse files
robtaylorclaude
andcommitted
Add automatic authentication with GitHub token and device flow support
This simplifies authentication for ChipFlow users by automatically trying multiple authentication methods in priority order. Authentication flow: 1. CHIPFLOW_API_KEY environment variable (if set) 2. Saved credentials from previous login (if exists) 3. GitHub CLI token authentication (if gh is available) 4. OAuth 2.0 Device Flow (as fallback) New features: - Add chipflow/auth.py module with get_api_key() helper - Add chipflow auth login/logout CLI commands - Update silicon_step.py to use automatic authentication - Save credentials to ~/.config/chipflow/credentials for reuse - Support for GitHub token endpoint (POST /auth/github-token) - Support for device flow endpoints Benefits: - Users just run "chipflow auth login" once - Instant authentication for users with gh CLI - Automatic fallback to device flow if needed - No manual API key copying required - Credentials persist across sessions Documentation: - Update getting-started.rst with new auth flow - Update chipflow-commands.rst with auth command docs - Add examples for both login methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 9cefb0a commit b21a9ba

File tree

6 files changed

+469
-26
lines changed

6 files changed

+469
-26
lines changed

chipflow/auth.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
# SPDX-License-Identifier: BSD-2-Clause
2+
3+
"""
4+
ChipFlow authentication helper module.
5+
6+
Handles authentication for ChipFlow API with multiple fallback methods:
7+
1. Environment variable CHIPFLOW_API_KEY
8+
2. GitHub CLI token authentication (if gh is available)
9+
3. OAuth 2.0 Device Flow
10+
"""
11+
12+
import logging
13+
import os
14+
import subprocess
15+
import sys
16+
import time
17+
import requests
18+
from pathlib import Path
19+
import json
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class AuthenticationError(Exception):
25+
"""Exception raised when authentication fails."""
26+
pass
27+
28+
29+
def get_credentials_file():
30+
"""Get path to credentials file."""
31+
config_dir = Path.home() / ".config" / "chipflow"
32+
return config_dir / "credentials"
33+
34+
35+
def save_api_key(api_key: str):
36+
"""Save API key to credentials file."""
37+
creds_file = get_credentials_file()
38+
creds_file.parent.mkdir(parents=True, exist_ok=True)
39+
40+
creds_data = {"api_key": api_key}
41+
creds_file.write_text(json.dumps(creds_data))
42+
creds_file.chmod(0o600)
43+
44+
logger.info(f"API key saved to {creds_file}")
45+
46+
47+
def load_saved_api_key():
48+
"""Load API key from credentials file if it exists."""
49+
creds_file = get_credentials_file()
50+
if not creds_file.exists():
51+
return None
52+
53+
try:
54+
creds_data = json.loads(creds_file.read_text())
55+
return creds_data.get("api_key")
56+
except (json.JSONDecodeError, KeyError):
57+
logger.warning(f"Invalid credentials file at {creds_file}")
58+
return None
59+
60+
61+
def is_gh_authenticated():
62+
"""Check if GitHub CLI is installed and authenticated."""
63+
try:
64+
result = subprocess.run(
65+
["gh", "auth", "status"],
66+
capture_output=True,
67+
text=True,
68+
timeout=5
69+
)
70+
return result.returncode == 0
71+
except (subprocess.TimeoutExpired, FileNotFoundError):
72+
return False
73+
74+
75+
def get_gh_token():
76+
"""Get GitHub token from gh CLI."""
77+
try:
78+
result = subprocess.run(
79+
["gh", "auth", "token"],
80+
capture_output=True,
81+
text=True,
82+
check=True,
83+
timeout=5
84+
)
85+
return result.stdout.strip()
86+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
87+
return None
88+
89+
90+
def authenticate_with_github_token(api_origin: str, interactive: bool = True):
91+
"""
92+
Authenticate using GitHub CLI token.
93+
94+
Args:
95+
api_origin: ChipFlow API origin URL
96+
interactive: Whether to show interactive messages
97+
98+
Returns:
99+
API key on success, None on failure
100+
"""
101+
if interactive:
102+
print("🔍 Checking for GitHub CLI authentication...")
103+
104+
if not is_gh_authenticated():
105+
if interactive:
106+
print("⚠️ GitHub CLI is not authenticated or not installed")
107+
return None
108+
109+
gh_token = get_gh_token()
110+
if not gh_token:
111+
if interactive:
112+
print("⚠️ Could not get GitHub token from gh CLI")
113+
return None
114+
115+
if interactive:
116+
print("🔑 Authenticating with GitHub token...")
117+
118+
try:
119+
response = requests.post(
120+
f"{api_origin}/auth/github-token",
121+
json={"github_token": gh_token},
122+
timeout=10
123+
)
124+
125+
if response.status_code == 200:
126+
api_key = response.json()["api_key"]
127+
save_api_key(api_key)
128+
if interactive:
129+
print("✅ Authenticated using GitHub CLI!")
130+
return api_key
131+
else:
132+
error_msg = response.json().get("error_description", "Unknown error")
133+
if interactive:
134+
print(f"⚠️ GitHub token authentication failed: {error_msg}")
135+
logger.debug(f"GitHub token auth failed: {response.status_code} - {error_msg}")
136+
return None
137+
138+
except requests.exceptions.RequestException as e:
139+
if interactive:
140+
print(f"⚠️ Network error during GitHub token authentication: {e}")
141+
logger.debug(f"Network error during GitHub token auth: {e}")
142+
return None
143+
144+
145+
def authenticate_with_device_flow(api_origin: str, interactive: bool = True):
146+
"""
147+
Authenticate using OAuth 2.0 Device Flow.
148+
149+
Args:
150+
api_origin: ChipFlow API origin URL
151+
interactive: Whether to show interactive messages
152+
153+
Returns:
154+
API key on success, raises AuthenticationError on failure
155+
"""
156+
if interactive:
157+
print("\n🌐 Starting device flow authentication...")
158+
159+
try:
160+
# Step 1: Initiate device flow
161+
response = requests.post(f"{api_origin}/auth/device/init", timeout=10)
162+
response.raise_for_status()
163+
data = response.json()
164+
165+
device_code = data["device_code"]
166+
user_code = data["user_code"]
167+
verification_uri = data["verification_uri"]
168+
interval = data["interval"]
169+
expires_in = data["expires_in"]
170+
171+
# Step 2: Display instructions
172+
if interactive:
173+
print(f"\n📋 To authenticate, please visit:\n {verification_uri}\n")
174+
print(f" And enter this code:\n {user_code}\n")
175+
print("⏳ Waiting for authorization...")
176+
177+
# Try to open browser
178+
try:
179+
import webbrowser
180+
webbrowser.open(verification_uri)
181+
except Exception:
182+
pass # Silently fail if browser opening doesn't work
183+
184+
# Step 3: Poll for authorization
185+
max_attempts = expires_in // interval
186+
for attempt in range(max_attempts):
187+
time.sleep(interval)
188+
189+
try:
190+
poll_response = requests.post(
191+
f"{api_origin}/auth/device/poll",
192+
json={"device_code": device_code},
193+
timeout=10
194+
)
195+
196+
if poll_response.status_code == 200:
197+
# Success!
198+
api_key = poll_response.json()["api_key"]
199+
save_api_key(api_key)
200+
if interactive:
201+
print("\n✅ Authentication successful!")
202+
return api_key
203+
204+
elif poll_response.status_code == 202:
205+
# Still pending
206+
if interactive and sys.stdout.isatty():
207+
print(".", end="", flush=True)
208+
continue
209+
210+
else:
211+
# Error
212+
error = poll_response.json()
213+
error_desc = error.get("error_description", "Unknown error")
214+
raise AuthenticationError(f"Device flow failed: {error_desc}")
215+
216+
except requests.exceptions.RequestException as e:
217+
logger.debug(f"Poll request failed: {e}")
218+
continue
219+
220+
raise AuthenticationError("Device flow authentication timed out")
221+
222+
except requests.exceptions.RequestException as e:
223+
raise AuthenticationError(f"Network error during device flow: {e}")
224+
225+
226+
def get_api_key(api_origin: str = None, interactive: bool = True, force_login: bool = False):
227+
"""
228+
Get API key using the following priority:
229+
1. CHIPFLOW_API_KEY environment variable
230+
2. Saved credentials file (unless force_login is True)
231+
3. GitHub CLI token authentication
232+
4. Device flow authentication
233+
234+
Args:
235+
api_origin: ChipFlow API origin URL (defaults to CHIPFLOW_API_ORIGIN env var or production)
236+
interactive: Whether to show interactive messages and prompts
237+
force_login: Force re-authentication even if credentials exist
238+
239+
Returns:
240+
API key string
241+
242+
Raises:
243+
AuthenticationError: If all authentication methods fail
244+
"""
245+
if api_origin is None:
246+
api_origin = os.environ.get("CHIPFLOW_API_ORIGIN", "https://build.chipflow.org")
247+
248+
# Method 1: Check environment variable
249+
api_key = os.environ.get("CHIPFLOW_API_KEY")
250+
if api_key:
251+
logger.debug("Using API key from CHIPFLOW_API_KEY environment variable")
252+
return api_key
253+
254+
# Check for deprecated env var
255+
api_key = os.environ.get("CHIPFLOW_API_KEY_SECRET")
256+
if api_key:
257+
if interactive:
258+
print("⚠️ CHIPFLOW_API_KEY_SECRET is deprecated. Please use CHIPFLOW_API_KEY instead.")
259+
logger.warning("Using deprecated CHIPFLOW_API_KEY_SECRET environment variable")
260+
return api_key
261+
262+
# Method 2: Check saved credentials (unless force_login)
263+
if not force_login:
264+
api_key = load_saved_api_key()
265+
if api_key:
266+
logger.debug("Using saved API key from credentials file")
267+
return api_key
268+
269+
# Method 3: Try GitHub CLI token authentication
270+
api_key = authenticate_with_github_token(api_origin, interactive=interactive)
271+
if api_key:
272+
return api_key
273+
274+
# Method 4: Fall back to device flow
275+
if interactive:
276+
print("\n💡 GitHub CLI not available. Using device flow authentication...")
277+
278+
try:
279+
return authenticate_with_device_flow(api_origin, interactive=interactive)
280+
except AuthenticationError as e:
281+
raise AuthenticationError(
282+
f"All authentication methods failed. {e}\n\n"
283+
"Please either:\n"
284+
" 1. Set CHIPFLOW_API_KEY environment variable\n"
285+
" 2. Install and authenticate with GitHub CLI: gh auth login\n"
286+
" 3. Complete the device flow authorization"
287+
)
288+
289+
290+
def logout():
291+
"""Remove saved credentials."""
292+
creds_file = get_credentials_file()
293+
if creds_file.exists():
294+
creds_file.unlink()
295+
print(f"✅ Logged out. Credentials removed from {creds_file}")
296+
else:
297+
print("ℹ️ No saved credentials found")

chipflow/auth_command.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# SPDX-License-Identifier: BSD-2-Clause
2+
3+
"""
4+
ChipFlow authentication command for CLI.
5+
6+
Provides `chipflow login` and `chipflow logout` commands.
7+
"""
8+
9+
import sys
10+
from .auth import get_api_key, logout as auth_logout, AuthenticationError
11+
from .utils import ChipFlowError
12+
13+
14+
class AuthCommand:
15+
"""Authentication management for ChipFlow."""
16+
17+
def __init__(self, config):
18+
"""Initialize the auth command.
19+
20+
Args:
21+
config: ChipFlow configuration object
22+
"""
23+
self.config = config
24+
25+
def build_cli_parser(self, parser):
26+
"""Build CLI argument parser for auth command."""
27+
subparsers = parser.add_subparsers(dest="action", required=True)
28+
29+
# Login command
30+
login_parser = subparsers.add_parser(
31+
"login",
32+
help="Authenticate with ChipFlow API"
33+
)
34+
login_parser.add_argument(
35+
"--force",
36+
action="store_true",
37+
help="Force re-authentication even if already logged in"
38+
)
39+
40+
# Logout command
41+
subparsers.add_parser(
42+
"logout",
43+
help="Remove saved credentials"
44+
)
45+
46+
def run_cli(self, args):
47+
"""Execute the auth command based on parsed arguments."""
48+
if args.action == "login":
49+
self._login(force=args.force)
50+
elif args.action == "logout":
51+
self._logout()
52+
else:
53+
raise ChipFlowError(f"Unknown auth action: {args.action}")
54+
55+
def _login(self, force=False):
56+
"""Perform login/authentication."""
57+
import os
58+
59+
api_origin = os.environ.get("CHIPFLOW_API_ORIGIN", "https://build.chipflow.org")
60+
61+
print(f"🔐 Authenticating with ChipFlow API ({api_origin})...")
62+
63+
try:
64+
api_key = get_api_key(
65+
api_origin=api_origin,
66+
interactive=True,
67+
force_login=force
68+
)
69+
print("\n✅ Successfully authenticated!")
70+
print(f" API key: {api_key[:20]}...")
71+
print("\n💡 You can now use `chipflow silicon submit` to submit designs")
72+
73+
except AuthenticationError as e:
74+
print(f"\n❌ Authentication failed: {e}")
75+
sys.exit(1)
76+
77+
def _logout(self):
78+
"""Perform logout."""
79+
auth_logout()

chipflow/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
_parse_config,
1616
)
1717
from .packaging import PinCommand
18+
from .auth_command import AuthCommand
1819

1920
class UnexpectedError(ChipFlowError):
2021
pass
@@ -34,6 +35,7 @@ def run(argv=sys.argv[1:]):
3435

3536
commands = {}
3637
commands["pin"] = PinCommand(config)
38+
commands["auth"] = AuthCommand(config)
3739

3840
if config.chipflow.steps:
3941
steps = DEFAULT_STEPS |config.chipflow.steps

0 commit comments

Comments
 (0)