Skip to main content

exeora_cli/auth/
mod.rs

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}