Skip to content

Commit 145fe99

Browse files
prmths128SaboniAmineinimaz
authored
fix: Cli OIDC fix (#1066)
* feat: add missing oidc auth on cli * fix: add authlib to core dependencies fix: fix tests * test: add auth tests for the cli --------- Co-authored-by: Amine Saboni <saboni.amine@gmail.com> Co-authored-by: inimaz <93inigo93@gmail.com>
1 parent 0b37824 commit 145fe99

6 files changed

Lines changed: 380 additions & 42 deletions

File tree

codecarbon/cli/auth.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
OIDC Authentication helpers for the CodeCarbon CLI.
3+
4+
Handles the full token lifecycle: browser-based login (Authorization Code +
5+
PKCE), credential storage, JWKS validation, and transparent refresh.
6+
"""
7+
8+
import json
9+
import os
10+
import webbrowser
11+
from http.server import BaseHTTPRequestHandler, HTTPServer
12+
from pathlib import Path
13+
from urllib.parse import parse_qs, urlparse
14+
15+
import requests
16+
from authlib.common.security import generate_token
17+
from authlib.integrations.requests_client import OAuth2Session
18+
from authlib.jose import JsonWebKey
19+
from authlib.jose import jwt as jose_jwt
20+
from authlib.oauth2.rfc7636 import create_s256_code_challenge
21+
22+
AUTH_CLIENT_ID = os.environ.get(
23+
"AUTH_CLIENT_ID",
24+
"codecarbon-cli",
25+
)
26+
AUTH_SERVER_WELL_KNOWN = os.environ.get(
27+
"AUTH_SERVER_WELL_KNOWN",
28+
"https://authentication.codecarbon.io/realms/codecarbon/.well-known/openid-configuration",
29+
)
30+
31+
_REDIRECT_PORT = 8090
32+
_REDIRECT_URI = f"http://localhost:{_REDIRECT_PORT}/callback"
33+
_CREDENTIALS_FILE = Path("./credentials.json")
34+
35+
36+
# ── OAuth callback server ───────────────────────────────────────────
37+
38+
39+
class _CallbackHandler(BaseHTTPRequestHandler):
40+
"""HTTP handler that captures the OAuth2 authorization callback."""
41+
42+
callback_url = None
43+
error = None
44+
45+
def do_GET(self):
46+
_CallbackHandler.callback_url = f"http://localhost:{_REDIRECT_PORT}{self.path}"
47+
parsed = urlparse(self.path)
48+
params = parse_qs(parsed.query)
49+
50+
if "error" in params:
51+
_CallbackHandler.error = params["error"][0]
52+
self.send_response(400)
53+
self.send_header("Content-Type", "text/html")
54+
self.end_headers()
55+
msg = params.get("error_description", [params["error"][0]])[0]
56+
self.wfile.write(
57+
f"<html><body><h1>Login failed</h1><p>{msg}</p></body></html>".encode()
58+
)
59+
else:
60+
self.send_response(200)
61+
self.send_header("Content-Type", "text/html")
62+
self.end_headers()
63+
self.wfile.write(
64+
b"<html><body><h1>Login successful!</h1>"
65+
b"<p>You can close this window.</p></body></html>"
66+
)
67+
68+
def log_message(self, format, *args):
69+
pass
70+
71+
72+
# ── OIDC discovery ──────────────────────────────────────────────────
73+
74+
75+
def _discover_endpoints():
76+
"""Fetch OpenID Connect discovery document."""
77+
resp = requests.get(AUTH_SERVER_WELL_KNOWN)
78+
resp.raise_for_status()
79+
return resp.json()
80+
81+
82+
# ── Credential storage ──────────────────────────────────────────────
83+
84+
85+
def _save_credentials(tokens):
86+
"""Save OAuth tokens to the local credentials file."""
87+
with open(_CREDENTIALS_FILE, "w") as f:
88+
json.dump(tokens, f)
89+
90+
91+
def _load_credentials():
92+
"""Load OAuth tokens from the local credentials file."""
93+
with open(_CREDENTIALS_FILE, "r") as f:
94+
return json.load(f)
95+
96+
97+
# ── Token validation & refresh ──────────────────────────────────────
98+
99+
100+
def _validate_access_token(access_token: str) -> bool:
101+
"""Validate access token against the current OIDC provider's JWKS.
102+
103+
Returns False when the signature or expiry check fails (wrong provider,
104+
expired, tampered). Returns True on network errors so the caller can
105+
fall through to the API and let the server decide.
106+
"""
107+
try:
108+
discovery = _discover_endpoints()
109+
jwks_resp = requests.get(discovery["jwks_uri"])
110+
jwks_resp.raise_for_status()
111+
keyset = JsonWebKey.import_key_set(jwks_resp.json())
112+
claims = jose_jwt.decode(access_token, keyset)
113+
claims.validate()
114+
return True
115+
except requests.RequestException:
116+
return True # Can't reach auth server — let the API handle it
117+
except Exception:
118+
return False
119+
120+
121+
def _refresh_tokens(refresh_token: str) -> dict:
122+
"""Exchange a refresh token for a new token set via the OIDC token endpoint."""
123+
discovery = _discover_endpoints()
124+
resp = requests.post(
125+
discovery["token_endpoint"],
126+
data={
127+
"grant_type": "refresh_token",
128+
"refresh_token": refresh_token,
129+
"client_id": AUTH_CLIENT_ID,
130+
},
131+
)
132+
resp.raise_for_status()
133+
return resp.json()
134+
135+
136+
# ── Public API ──────────────────────────────────────────────────────
137+
138+
139+
def authorize():
140+
"""Run the OAuth2 Authorization Code flow with PKCE."""
141+
discovery = _discover_endpoints()
142+
143+
session = OAuth2Session(
144+
client_id=AUTH_CLIENT_ID,
145+
redirect_uri=_REDIRECT_URI,
146+
scope="openid offline_access",
147+
token_endpoint_auth_method="none",
148+
)
149+
150+
code_verifier = generate_token(48)
151+
code_challenge = create_s256_code_challenge(code_verifier)
152+
153+
uri, state = session.create_authorization_url(
154+
discovery["authorization_endpoint"],
155+
code_challenge=code_challenge,
156+
code_challenge_method="S256",
157+
)
158+
159+
_CallbackHandler.callback_url = None
160+
_CallbackHandler.error = None
161+
162+
server = HTTPServer(("localhost", _REDIRECT_PORT), _CallbackHandler)
163+
164+
print("Opening browser for authentication...")
165+
webbrowser.open(uri)
166+
167+
server.handle_request()
168+
server.server_close()
169+
170+
if _CallbackHandler.error:
171+
raise ValueError(f"Authorization failed: {_CallbackHandler.error}")
172+
173+
if not _CallbackHandler.callback_url:
174+
raise ValueError("Authorization failed: no callback received")
175+
176+
token = session.fetch_token(
177+
discovery["token_endpoint"],
178+
authorization_response=_CallbackHandler.callback_url,
179+
code_verifier=code_verifier,
180+
)
181+
182+
_save_credentials(token)
183+
return token
184+
185+
186+
def get_access_token() -> str:
187+
"""Return a valid access token, refreshing or failing with a clear message."""
188+
try:
189+
creds = _load_credentials()
190+
except Exception as e:
191+
raise ValueError(
192+
"Not able to retrieve the access token, "
193+
f"please run `codecarbon login` first! (error: {e})"
194+
)
195+
196+
access_token = creds.get("access_token")
197+
if not access_token:
198+
raise ValueError("No access token found. Please run `codecarbon login` first.")
199+
200+
# Fast path: token is still valid for the current OIDC provider
201+
if _validate_access_token(access_token):
202+
return access_token
203+
204+
# Token is expired or was issued by a different provider — try refresh
205+
refresh_token = creds.get("refresh_token")
206+
if refresh_token:
207+
try:
208+
new_tokens = _refresh_tokens(refresh_token)
209+
_save_credentials(new_tokens)
210+
return new_tokens["access_token"]
211+
except Exception:
212+
pass
213+
214+
# Refresh failed — credentials are stale (e.g. auth provider migrated)
215+
_CREDENTIALS_FILE.unlink(missing_ok=True)
216+
raise ValueError(
217+
"Your session has expired or the authentication provider has changed. "
218+
"Please run `codecarbon login` again."
219+
)
220+
221+
222+
def get_id_token() -> str:
223+
"""Return the stored OIDC id_token."""
224+
creds = _load_credentials()
225+
return creds["id_token"]

codecarbon/cli/main.py

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@
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
1614

1715
from codecarbon import __app_name__, __version__
16+
from codecarbon.cli.auth import authorize, get_access_token
1817
from codecarbon.cli.cli_utils import (
1918
create_new_config_file,
2019
get_api_endpoint,
@@ -27,13 +26,6 @@
2726
from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate, ProjectCreate
2827
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
2928

30-
AUTH_CLIENT_ID = os.environ.get(
31-
"AUTH_CLIENT_ID",
32-
"jsUPWIcUECQFE_ouanUuVhXx52TTjEVcVNNtNGeyAtU",
33-
)
34-
AUTH_SERVER_URL = os.environ.get(
35-
"AUTH_SERVER_URL", "https://auth.codecarbon.io/codecarbon"
36-
)
3729
API_URL = os.environ.get("API_URL", "https://dashboard.codecarbon.io/api")
3830

3931
DEFAULT_PROJECT_ID = "e60afa92-17b7-4720-91a0-1ae91e409ba1"
@@ -79,7 +71,7 @@ def show_config(path: Path = Path("./.codecarbon.config")) -> None:
7971
d = get_config(path)
8072
api_endpoint = get_api_endpoint(path)
8173
api = ApiClient(endpoint_url=api_endpoint)
82-
api.set_access_token(_get_access_token())
74+
api.set_access_token(get_access_token())
8375
print("Current configuration : \n")
8476
print("Config file content : ")
8577
print(d)
@@ -115,28 +107,6 @@ def show_config(path: Path = Path("./.codecarbon.config")) -> None:
115107
)
116108

117109

118-
def get_fief_auth():
119-
fief = Fief(AUTH_SERVER_URL, AUTH_CLIENT_ID)
120-
fief_auth = FiefAuth(fief, "./credentials.json")
121-
return fief_auth
122-
123-
124-
def _get_access_token():
125-
try:
126-
access_token_info = get_fief_auth().access_token_info()
127-
access_token = access_token_info["access_token"]
128-
return access_token
129-
except Exception as e:
130-
raise ValueError(
131-
f"Not able to retrieve the access token, please run `codecarbon login` first! (error: {e})"
132-
)
133-
134-
135-
def _get_id_token():
136-
id_token = get_fief_auth()._tokens["id_token"]
137-
return id_token
138-
139-
140110
@codecarbon.command(
141111
"test-api", short_help="Make an authenticated GET request to an API endpoint"
142112
)
@@ -145,16 +115,16 @@ def api_get():
145115
ex: test-api
146116
"""
147117
api = ApiClient(endpoint_url=API_URL) # TODO: get endpoint from config
148-
api.set_access_token(_get_access_token())
118+
api.set_access_token(get_access_token())
149119
organizations = api.get_list_organizations()
150120
print(organizations)
151121

152122

153123
@codecarbon.command("login", short_help="Login to CodeCarbon")
154124
def login():
155-
get_fief_auth().authorize()
125+
authorize()
156126
api = ApiClient(endpoint_url=API_URL) # TODO: get endpoint from config
157-
access_token = _get_access_token()
127+
access_token = get_access_token()
158128
api.set_access_token(access_token)
159129
api.check_auth()
160130

@@ -167,7 +137,7 @@ def get_api_key(project_id: str):
167137
"name": "api token",
168138
"x_token": "???",
169139
},
170-
headers={"Authorization": f"Bearer {_get_access_token()}"},
140+
headers={"Authorization": f"Bearer {get_access_token()}"},
171141
)
172142
api_key = req.json()["token"]
173143
return api_key
@@ -176,7 +146,7 @@ def get_api_key(project_id: str):
176146
@codecarbon.command("get-token", short_help="Get project token")
177147
def get_token(project_id: str):
178148
# api = ApiClient(endpoint_url=API_URL) # TODO: get endpoint from config
179-
# api.set_access_token(_get_access_token())
149+
# api.set_access_token(get_access_token())
180150
token = get_api_key(project_id)
181151
print("Your token: " + token)
182152
print("Add it to the api_key field in your configuration file")
@@ -224,7 +194,7 @@ def config():
224194
)
225195
overwrite_local_config("api_endpoint", api_endpoint, path=file_path)
226196
api = ApiClient(endpoint_url=api_endpoint)
227-
api.set_access_token(_get_access_token())
197+
api.set_access_token(get_access_token())
228198
organizations = api.get_list_organizations()
229199
org = questionary_prompt(
230200
"Pick existing organization from list or Create new organization ?",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ classifiers = [
2929
]
3030
dependencies = [
3131
"arrow",
32+
"authlib>=1.2.1",
3233
"click",
33-
"fief-client[cli]",
3434
"pandas>=2.3.3;python_version>='3.14'",
3535
"pandas;python_version<'3.14'",
3636
"prometheus_client",
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_app(self, MockApiClient):
5757
@patch("codecarbon.cli.main.Path.exists")
5858
@patch("codecarbon.cli.main.Confirm.ask")
5959
@patch("codecarbon.cli.main.questionary_prompt")
60-
@patch("codecarbon.cli.main._get_access_token")
60+
@patch("codecarbon.cli.main.get_access_token")
6161
@patch("typer.prompt")
6262
def test_config_no_local_new_all(
6363
self,
@@ -147,7 +147,7 @@ def side_effect_wrapper(*args, **kwargs):
147147
except OSError:
148148
pass
149149

150-
@patch("codecarbon.cli.main._get_access_token")
150+
@patch("codecarbon.cli.main.get_access_token")
151151
@patch("codecarbon.cli.main.Path.exists")
152152
@patch("codecarbon.cli.main.get_config")
153153
@patch("codecarbon.cli.main.questionary_prompt")

0 commit comments

Comments
 (0)