1use crate::config::credential_fallback_path;
2use anyhow::{Context, Result, anyhow, bail};
3use axum::{
4 Router,
5 extract::{Query, State},
6 http::StatusCode,
7 response::Html,
8 routing::get,
9};
10use keyring::Entry;
11use oauth2::{
12 AuthUrl, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenUrl,
13 basic::BasicClient,
14};
15use serde::{Deserialize, Serialize};
16use std::{
17 collections::HashMap,
18 fs,
19 io::ErrorKind,
20 path::Path,
21 sync::{Arc, Mutex},
22};
23use tokio::sync::{Mutex as AsyncMutex, oneshot};
24use url::Url;
25
26const SERVICE: &str = "exeora";
27const ACCOUNT: &str = "refresh-token";
28const EARLY_REFRESH_MS: u64 = 60_000;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct StoredCredentials {
33 pub refresh_token: String,
34 pub issuer: String,
35}
36
37#[derive(Debug, Clone, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct CliClientInfo {
40 pub client_id: String,
41 pub authorization_endpoint: String,
42 pub token_endpoint: String,
43 pub scopes: Vec<String>,
44}
45
46#[derive(Debug, Clone)]
47struct CachedToken {
48 token: String,
49 expires_at: u64,
50}
51
52pub struct AuthManager {
53 gateway: String,
54 http: reqwest::Client,
55 cached: AsyncMutex<Option<CachedToken>>,
56}
57
58impl AuthManager {
59 pub fn new(gateway: String, http: reqwest::Client) -> Self {
60 Self {
61 gateway,
62 http,
63 cached: AsyncMutex::new(None),
64 }
65 }
66
67 pub async fn discover_client(&self) -> Result<CliClientInfo> {
68 discover_client(&self.http, &self.gateway).await
69 }
70
71 pub async fn access_token(&self) -> Result<String> {
72 {
73 let cached = self.cached.lock().await;
74 if let Some(cached) = cached.as_ref()
75 && cached.expires_at.saturating_sub(EARLY_REFRESH_MS) > crate::protocol::now_ms()
76 {
77 return Ok(cached.token.clone());
78 }
79 }
80
81 let credentials = load_credentials()?
82 .ok_or_else(|| anyhow!("Not signed in. Run `exeora login` first."))?;
83 let origin = Url::parse(&self.gateway)?.origin().ascii_serialization();
84 if credentials.issuer != origin {
85 bail!(
86 "You are signed in to {}, but the configured gateway is {}. Run `exeora login` again.",
87 credentials.issuer,
88 self.gateway
89 );
90 }
91 let client = self.discover_client().await?;
92 let response = self
93 .http
94 .post(&client.token_endpoint)
95 .form(&[
96 ("grant_type", "refresh_token"),
97 ("client_id", client.client_id.as_str()),
98 ("refresh_token", credentials.refresh_token.as_str()),
99 ])
100 .send()
101 .await?;
102 if matches!(response.status().as_u16(), 400 | 401) {
103 clear_credentials()?;
104 bail!("Not signed in. Run `exeora login` first.");
105 }
106 if !response.status().is_success() {
107 bail!(
108 "Could not refresh the session ({}).",
109 response.status().as_u16()
110 );
111 }
112 let token: RefreshResponse = response.json().await?;
113 let access = token
114 .access_token
115 .ok_or_else(|| anyhow!("The gateway returned no access token."))?;
116 let expires_at = crate::protocol::now_ms() + token.expires_in.unwrap_or(3600) * 1000;
117 *self.cached.lock().await = Some(CachedToken {
118 token: access.clone(),
119 expires_at,
120 });
121 Ok(access)
122 }
123
124 pub async fn cache_access_token(&self, token: String, expires_at: u64) {
125 *self.cached.lock().await = Some(CachedToken { token, expires_at });
126 }
127
128 pub async fn forget_access_token(&self) {
129 *self.cached.lock().await = None;
130 }
131
132 pub async fn login_browser(&self) -> Result<LoginResult> {
133 let info = self.discover_client().await?;
134 let state = CsrfToken::new_random();
135 let expected_state = state.secret().clone();
136 let (redirect_uri, callback) = start_loopback(expected_state).await?;
137 let oauth = BasicClient::new(ClientId::new(info.client_id.clone()))
138 .set_auth_uri(AuthUrl::new(info.authorization_endpoint)?)
139 .set_token_uri(TokenUrl::new(info.token_endpoint.clone())?)
140 .set_redirect_uri(RedirectUrl::new(redirect_uri.clone())?);
141 let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
142 let mut request = oauth
143 .authorize_url(move || state)
144 .set_pkce_challenge(challenge);
145 for scope in &info.scopes {
146 request = request.add_scope(Scope::new(scope.clone()));
147 }
148 let (authorize_url, _) = request.url();
149 open::that(authorize_url.as_str()).context("Could not open the browser")?;
150 println!("\nIf your browser did not open, visit:\n{authorize_url}\n");
151 let returned = tokio::time::timeout(std::time::Duration::from_secs(300), callback)
152 .await
153 .map_err(|_| {
154 anyhow!("Timed out waiting for the browser. Try `exeora login` again.")
155 })???;
156
157 let expected_issuer = Url::parse(&self.gateway)?.origin().ascii_serialization();
158 if let Some(issuer) = returned.issuer
159 && issuer != expected_issuer
160 {
161 bail!(
162 "The authorization came back from {issuer}, not {}. Aborting.",
163 self.gateway
164 );
165 }
166 let response = self
167 .http
168 .post(&info.token_endpoint)
169 .form(&[
170 ("grant_type", "authorization_code"),
171 ("client_id", info.client_id.as_str()),
172 ("code", returned.code.as_str()),
173 ("redirect_uri", redirect_uri.as_str()),
174 ("code_verifier", verifier.secret()),
175 ])
176 .send()
177 .await?;
178 if !response.status().is_success() {
179 let status = response.status().as_u16();
180 let detail = response.text().await.unwrap_or_default();
181 bail!(
182 "Token exchange failed ({status}): {}",
183 detail.chars().take(200).collect::<String>()
184 );
185 }
186 let token: LoginTokenResponse = response.json().await?;
187 save_credentials(&StoredCredentials {
188 refresh_token: token.refresh_token,
189 issuer: expected_issuer,
190 })?;
191 let result = LoginResult {
192 access_token: token.access_token,
193 expires_at: crate::protocol::now_ms() + token.expires_in * 1000,
194 };
195 self.cache_access_token(result.access_token.clone(), result.expires_at)
196 .await;
197 Ok(result)
198 }
199}
200
201#[derive(Debug, Deserialize)]
202struct RefreshResponse {
203 access_token: Option<String>,
204 expires_in: Option<u64>,
205}
206
207#[derive(Debug, Deserialize)]
208struct LoginTokenResponse {
209 access_token: String,
210 refresh_token: String,
211 expires_in: u64,
212}
213
214pub struct LoginResult {
215 pub access_token: String,
216 pub expires_at: u64,
217}
218
219pub async fn discover_client(http: &reqwest::Client, gateway: &str) -> Result<CliClientInfo> {
220 let url = Url::parse(gateway)?.join("/oauth/cli-client")?;
221 let response = http
222 .get(url)
223 .send()
224 .await
225 .with_context(|| format!("Could not reach the Exeora gateway at {gateway}"))?;
226 if !response.status().is_success() {
227 bail!(
228 "Could not reach the Exeora gateway at {gateway} ({}).",
229 response.status().as_u16()
230 );
231 }
232 Ok(response.json().await?)
233}
234
235pub fn save_credentials(credentials: &StoredCredentials) -> Result<()> {
236 let serialized = serde_json::to_string(credentials)?;
237 if let Ok(entry) = Entry::new(SERVICE, ACCOUNT)
238 && entry.set_password(&serialized).is_ok()
239 {
240 let _ = fs::remove_file(credential_fallback_path()?);
241 return Ok(());
242 }
243 let path = credential_fallback_path()?;
244 if let Some(parent) = path.parent() {
245 fs::create_dir_all(parent)?;
246 }
247 write_secret_file(&path, serialized.as_bytes())?;
248 Ok(())
249}
250
251pub fn load_credentials() -> Result<Option<StoredCredentials>> {
252 if let Ok(entry) = Entry::new(SERVICE, ACCOUNT)
253 && let Ok(value) = entry.get_password()
254 && !value.is_empty()
255 {
256 return Ok(Some(serde_json::from_str(&value)?));
257 }
258 match fs::read(credential_fallback_path()?) {
259 Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
260 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
261 Err(error) => Err(error.into()),
262 }
263}
264
265pub fn clear_credentials() -> Result<()> {
266 if let Ok(entry) = Entry::new(SERVICE, ACCOUNT) {
267 let _ = entry.delete_credential();
268 }
269 match fs::remove_file(credential_fallback_path()?) {
270 Ok(()) => Ok(()),
271 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
272 Err(error) => Err(error.into()),
273 }
274}
275
276pub fn using_file_fallback() -> bool {
277 Entry::new(SERVICE, ACCOUNT)
278 .and_then(|entry| entry.get_password())
279 .is_err()
280}
281
282#[cfg(unix)]
283fn write_secret_file(path: &Path, bytes: &[u8]) -> Result<()> {
284 use std::io::Write;
285 use std::os::unix::fs::OpenOptionsExt;
286 let mut file = fs::OpenOptions::new()
287 .create(true)
288 .truncate(true)
289 .write(true)
290 .mode(0o600)
291 .open(path)?;
292 file.write_all(bytes)?;
293 Ok(())
294}
295
296#[cfg(not(unix))]
297fn write_secret_file(path: &Path, bytes: &[u8]) -> Result<()> {
298 fs::write(path, bytes).map_err(Into::into)
299}
300
301#[derive(Clone)]
302struct CallbackState {
303 expected_state: String,
304 result: Arc<Mutex<Option<oneshot::Sender<Result<CallbackResult>>>>>,
305 shutdown: Arc<Mutex<Option<oneshot::Sender<()>>>>,
306}
307
308struct CallbackResult {
309 code: String,
310 issuer: Option<String>,
311}
312
313async fn start_loopback(
314 expected_state: String,
315) -> Result<(String, oneshot::Receiver<Result<CallbackResult>>)> {
316 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
317 let port = listener.local_addr()?.port();
318 let (result_tx, result_rx) = oneshot::channel();
319 let (shutdown_tx, shutdown_rx) = oneshot::channel();
320 let state = CallbackState {
321 expected_state,
322 result: Arc::new(Mutex::new(Some(result_tx))),
323 shutdown: Arc::new(Mutex::new(Some(shutdown_tx))),
324 };
325 let app = Router::new()
326 .route("/callback", get(callback))
327 .with_state(state);
328 tokio::spawn(async move {
329 let _ = axum::serve(listener, app)
330 .with_graceful_shutdown(async {
331 let _ = shutdown_rx.await;
332 })
333 .await;
334 });
335 Ok((format!("http://127.0.0.1:{port}/callback"), result_rx))
336}
337
338async fn callback(
339 State(state): State<CallbackState>,
340 Query(query): Query<HashMap<String, String>>,
341) -> (StatusCode, Html<&'static str>) {
342 let result = if let Some(error) = query.get("error") {
343 Err(anyhow!("Authorization was declined ({error})."))
344 } else if query.get("state") != Some(&state.expected_state) {
345 Err(anyhow!(
346 "The authorization response did not match this login attempt."
347 ))
348 } else if let Some(code) = query.get("code") {
349 Ok(CallbackResult {
350 code: code.clone(),
351 issuer: query.get("iss").cloned(),
352 })
353 } else {
354 Err(anyhow!("No authorization code was returned."))
355 };
356 let ok = result.is_ok();
357 if let Some(sender) = state.result.lock().expect("callback result lock").take() {
358 let _ = sender.send(result);
359 }
360 if let Some(sender) = state
361 .shutdown
362 .lock()
363 .expect("callback shutdown lock")
364 .take()
365 {
366 let _ = sender.send(());
367 }
368 if ok {
369 (
370 StatusCode::OK,
371 Html(
372 "<!doctype html><meta charset=utf-8><title>Exeora</title><p>Signed in. You can close this tab and return to the terminal.</p>",
373 ),
374 )
375 } else {
376 (
377 StatusCode::BAD_REQUEST,
378 Html(
379 "<!doctype html><meta charset=utf-8><title>Exeora</title><p>Authorization failed. You can close this tab.</p>",
380 ),
381 )
382 }
383}