Skip to content

Commit 0ee3375

Browse files
committed
remove fief from cli auth
1 parent 46f5d39 commit 0ee3375

File tree

3 files changed

+255
-10
lines changed

3 files changed

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