Skip to content

Commit 53195cf

Browse files
committed
remove fief from cli auth
1 parent 46f5d39 commit 53195cf

File tree

3 files changed

+246
-10
lines changed

3 files changed

+246
-10
lines changed

codecarbon/cli/main.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
import questionary
99
import requests
1010
import typer
11-
from fief_client import Fief
12-
from fief_client.integrations.cli import FiefAuth
1311
from rich import print
1412
from rich.prompt import Confirm
1513
from typing_extensions import Annotated
@@ -22,6 +20,7 @@
2220
get_existing_local_exp_id,
2321
overwrite_local_config,
2422
)
23+
from codecarbon.cli.oidc_auth import OIDCAuth
2524
from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone
2625
from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate, ProjectCreate
2726
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
@@ -114,15 +113,14 @@ def show_config(path: Path = Path("./.codecarbon.config")) -> None:
114113
)
115114

116115

117-
def get_fief_auth():
118-
fief = Fief(AUTH_SERVER_URL, AUTH_CLIENT_ID)
119-
fief_auth = FiefAuth(fief, "./credentials.json")
120-
return fief_auth
116+
def get_oidc_auth():
117+
oidc_auth = OIDCAuth(AUTH_SERVER_URL, AUTH_CLIENT_ID, "./credentials.json")
118+
return oidc_auth
121119

122120

123121
def _get_access_token():
124122
try:
125-
access_token_info = get_fief_auth().access_token_info()
123+
access_token_info = get_oidc_auth().access_token_info()
126124
access_token = access_token_info["access_token"]
127125
return access_token
128126
except Exception as e:
@@ -132,7 +130,7 @@ def _get_access_token():
132130

133131

134132
def _get_id_token():
135-
id_token = get_fief_auth()._tokens["id_token"]
133+
id_token = get_oidc_auth().get_id_token()
136134
return id_token
137135

138136

@@ -151,7 +149,7 @@ def api_get():
151149

152150
@codecarbon.command("login", short_help="Login to CodeCarbon")
153151
def login():
154-
get_fief_auth().authorize()
152+
get_oidc_auth().authorize()
155153
api = ApiClient(endpoint_url=API_URL) # TODO: get endpoint from config
156154
access_token = _get_access_token()
157155
api.set_access_token(access_token)

codecarbon/cli/oidc_auth.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""
2+
OIDC Authentication module for CodeCarbon CLI.
3+
4+
This module replaces the deprecated fief-client library with a standard
5+
OIDC implementation using python-jose for JWT validation.
6+
"""
7+
8+
import hashlib
9+
import json
10+
import secrets
11+
import webbrowser
12+
from base64 import urlsafe_b64encode
13+
from http.server import BaseHTTPRequestHandler, HTTPServer
14+
from pathlib import Path
15+
from threading import Thread
16+
from typing import Dict, Optional
17+
from urllib.parse import parse_qs, urlencode, urlparse
18+
19+
import requests
20+
from jose import jwt
21+
from jose.exceptions import JWTError
22+
23+
24+
class OIDCAuth:
25+
"""
26+
Uses Authorization Code flow with PKCE for secure authentication.
27+
Stores tokens in a local credentials file.
28+
"""
29+
30+
def __init__(self, server_url: str, client_id: str, credentials_file: str = "./credentials.json"):
31+
32+
self.server_url = server_url.rstrip('/')
33+
self.client_id = client_id
34+
self.credentials_file = Path(credentials_file)
35+
self._tokens: Optional[Dict] = None
36+
self._oidc_config: Optional[Dict] = None
37+
self._jwks: Optional[Dict] = None
38+
39+
# Load existing credentials
40+
self._load_credentials()
41+
42+
def _get_oidc_configuration(self) -> Dict:
43+
if self._oidc_config is None:
44+
config_url = f"{self.server_url}/.well-known/openid-configuration"
45+
response = requests.get(config_url)
46+
response.raise_for_status()
47+
self._oidc_config = response.json()
48+
return self._oidc_config
49+
50+
def _get_jwks(self) -> Dict:
51+
if self._jwks is None:
52+
config = self._get_oidc_configuration()
53+
jwks_uri = config['jwks_uri']
54+
response = requests.get(jwks_uri)
55+
response.raise_for_status()
56+
self._jwks = response.json()
57+
return self._jwks
58+
59+
def _generate_pkce_pair(self):
60+
code_verifier = urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
61+
code_challenge = urlsafe_b64encode(
62+
hashlib.sha256(code_verifier.encode('utf-8')).digest()
63+
).decode('utf-8').rstrip('=')
64+
return code_verifier, code_challenge
65+
66+
def _load_credentials(self):
67+
if self.credentials_file.exists():
68+
try:
69+
with open(self.credentials_file, 'r') as f:
70+
self._tokens = json.load(f)
71+
except (json.JSONDecodeError, IOError):
72+
self._tokens = None
73+
74+
def _save_credentials(self):
75+
if self._tokens:
76+
self.credentials_file.parent.mkdir(parents=True, exist_ok=True)
77+
with open(self.credentials_file, 'w') as f:
78+
json.dump(self._tokens, f, indent=2)
79+
80+
def authorize(self, redirect_port: int = 51562):
81+
config = self._get_oidc_configuration()
82+
authorization_endpoint = config['authorization_endpoint']
83+
token_endpoint = config['token_endpoint']
84+
85+
code_verifier, code_challenge = self._generate_pkce_pair()
86+
state = secrets.token_urlsafe(32)
87+
88+
redirect_uri = f"http://localhost:{redirect_port}/callback"
89+
90+
auth_params = {
91+
'client_id': self.client_id,
92+
'response_type': 'code',
93+
'redirect_uri': redirect_uri,
94+
'scope': 'openid profile email',
95+
'state': state,
96+
'code_challenge': code_challenge,
97+
'code_challenge_method': 'S256',
98+
}
99+
auth_url = f"{authorization_endpoint}?{urlencode(auth_params)}"
100+
101+
authorization_code = None
102+
server_error = None
103+
104+
class CallbackHandler(BaseHTTPRequestHandler):
105+
def log_message(self, format, *args):
106+
# Suppress server logs
107+
pass
108+
109+
def do_GET(self):
110+
nonlocal authorization_code, server_error
111+
112+
parsed = urlparse(self.path)
113+
params = parse_qs(parsed.query)
114+
115+
if 'code' in params and 'state' in params:
116+
if params['state'][0] == state:
117+
authorization_code = params['code'][0]
118+
self.send_response(200)
119+
self.send_header('Content-type', 'text/html')
120+
self.end_headers()
121+
self.wfile.write(b'<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>')
122+
else:
123+
server_error = "State mismatch"
124+
self.send_response(400)
125+
self.end_headers()
126+
elif 'error' in params:
127+
server_error = params['error'][0]
128+
self.send_response(400)
129+
self.end_headers()
130+
131+
server = HTTPServer(('localhost', redirect_port), CallbackHandler)
132+
server_thread = Thread(target=server.handle_request, daemon=True)
133+
server_thread.start()
134+
print(f"Opening browser for authentication...")
135+
webbrowser.open(auth_url)
136+
server_thread.join(timeout=300) # 5 minute timeout
137+
server.server_close()
138+
139+
if server_error:
140+
raise Exception(f"Authorization failed: {server_error}")
141+
142+
if not authorization_code:
143+
raise Exception("Authorization timed out or was cancelled")
144+
145+
# Exchange code for tokens
146+
token_params = {
147+
'grant_type': 'authorization_code',
148+
'code': authorization_code,
149+
'redirect_uri': redirect_uri,
150+
'client_id': self.client_id,
151+
'code_verifier': code_verifier,
152+
}
153+
154+
response = requests.post(token_endpoint, data=token_params)
155+
response.raise_for_status()
156+
self._tokens = response.json()
157+
self._save_credentials()
158+
159+
print("Authentication successful!")
160+
161+
def _refresh_tokens(self):
162+
"""Refresh access token using refresh token."""
163+
if not self._tokens or 'refresh_token' not in self._tokens:
164+
raise Exception("No refresh token available")
165+
166+
config = self._get_oidc_configuration()
167+
token_endpoint = config['token_endpoint']
168+
169+
token_params = {
170+
'grant_type': 'refresh_token',
171+
'refresh_token': self._tokens['refresh_token'],
172+
'client_id': self.client_id,
173+
}
174+
175+
response = requests.post(token_endpoint, data=token_params)
176+
response.raise_for_status()
177+
self._tokens = response.json()
178+
self._save_credentials()
179+
180+
# def _validate_token(self, token: str) -> Dict:
181+
# try:
182+
# jwks = self._get_jwks()
183+
# # Decode and validate
184+
# claims = jwt.decode(
185+
# token,
186+
# jwks,
187+
# algorithms=['RS256'],
188+
# audience=self.client_id,
189+
# issuer=self.server_url,
190+
# )
191+
# return claims
192+
# except JWTError as e:
193+
# raise Exception(f"Token validation failed: {e}")
194+
195+
196+
197+
def _validate_token(self, token: str) -> Dict:
198+
try:
199+
claims = jwt.get_unverified_claims(token)
200+
import time
201+
if 'exp' in claims and claims['exp'] < time.time():
202+
raise Exception("Token expired")
203+
return claims
204+
except JWTError as e:
205+
raise Exception(f"Token validation failed: {e}")
206+
207+
208+
209+
def access_token_info(self) -> Dict:
210+
if not self._tokens or 'access_token' not in self._tokens:
211+
raise Exception("Not authenticated. Please run login first.")
212+
213+
access_token = self._tokens['access_token']
214+
215+
try:
216+
claims = self._validate_token(access_token)
217+
return {
218+
'access_token': access_token,
219+
'claims': claims,
220+
}
221+
except Exception:
222+
# Token might be expired, try to refresh
223+
try:
224+
self._refresh_tokens()
225+
access_token = self._tokens['access_token']
226+
claims = self._validate_token(access_token)
227+
return {
228+
'access_token': access_token,
229+
'claims': claims,
230+
}
231+
except Exception as e:
232+
raise Exception(f"Failed to get valid access token: {e}")
233+
234+
def get_id_token(self) -> str:
235+
if not self._tokens or 'id_token' not in self._tokens:
236+
raise Exception("Not authenticated. Please run login first.")
237+
238+
return self._tokens['id_token']

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ classifiers = [
3030
dependencies = [
3131
"arrow",
3232
"click",
33-
"fief-client[cli]",
33+
"python-jose[cryptography]",
3434
"pandas>=2.3.3;python_version>='3.14'",
3535
"pandas;python_version<'3.14'",
3636
"prometheus_client",

0 commit comments

Comments
 (0)