use crate::config::credential_fallback_path;
use anyhow::{Context, Result, anyhow, bail};
use axum::{
Router,
extract::{Query, State},
http::StatusCode,
response::Html,
routing::get,
};
use keyring::Entry;
use oauth2::{
AuthUrl, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenUrl,
basic::BasicClient,
};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs,
io::ErrorKind,
path::Path,
sync::{Arc, Mutex},
};
use tokio::sync::{Mutex as AsyncMutex, oneshot};
use url::Url;
const SERVICE: &str = "exeora";
const ACCOUNT: &str = "refresh-token";
const EARLY_REFRESH_MS: u64 = 60_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredCredentials {
pub refresh_token: String,
pub issuer: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CliClientInfo {
pub client_id: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub scopes: Vec<String>,
}
#[derive(Debug, Clone)]
struct CachedToken {
token: String,
expires_at: u64,
}
pub struct AuthManager {
gateway: String,
http: reqwest::Client,
cached: AsyncMutex<Option<CachedToken>>,
}
impl AuthManager {
pub fn new(gateway: String, http: reqwest::Client) -> Self {
Self {
gateway,
http,
cached: AsyncMutex::new(None),
}
}
pub async fn discover_client(&self) -> Result<CliClientInfo> {
discover_client(&self.http, &self.gateway).await
}
pub async fn access_token(&self) -> Result<String> {
{
let cached = self.cached.lock().await;
if let Some(cached) = cached.as_ref()
&& cached.expires_at.saturating_sub(EARLY_REFRESH_MS) > crate::protocol::now_ms()
{
return Ok(cached.token.clone());
}
}
let credentials = load_credentials()?
.ok_or_else(|| anyhow!("Not signed in. Run `exeora login` first."))?;
let origin = Url::parse(&self.gateway)?.origin().ascii_serialization();
if credentials.issuer != origin {
bail!(
"You are signed in to {}, but the configured gateway is {}. Run `exeora login` again.",
credentials.issuer,
self.gateway
);
}
let client = self.discover_client().await?;
let response = self
.http
.post(&client.token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("client_id", client.client_id.as_str()),
("refresh_token", credentials.refresh_token.as_str()),
])
.send()
.await?;
if matches!(response.status().as_u16(), 400 | 401) {
clear_credentials()?;
bail!("Not signed in. Run `exeora login` first.");
}
if !response.status().is_success() {
bail!(
"Could not refresh the session ({}).",
response.status().as_u16()
);
}
let token: RefreshResponse = response.json().await?;
let access = token
.access_token
.ok_or_else(|| anyhow!("The gateway returned no access token."))?;
let expires_at = crate::protocol::now_ms() + token.expires_in.unwrap_or(3600) * 1000;
*self.cached.lock().await = Some(CachedToken {
token: access.clone(),
expires_at,
});
Ok(access)
}
pub async fn cache_access_token(&self, token: String, expires_at: u64) {
*self.cached.lock().await = Some(CachedToken { token, expires_at });
}
pub async fn forget_access_token(&self) {
*self.cached.lock().await = None;
}
pub async fn login_browser(&self) -> Result<LoginResult> {
let info = self.discover_client().await?;
let state = CsrfToken::new_random();
let expected_state = state.secret().clone();
let (redirect_uri, callback) = start_loopback(expected_state).await?;
let oauth = BasicClient::new(ClientId::new(info.client_id.clone()))
.set_auth_uri(AuthUrl::new(info.authorization_endpoint)?)
.set_token_uri(TokenUrl::new(info.token_endpoint.clone())?)
.set_redirect_uri(RedirectUrl::new(redirect_uri.clone())?);
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
let mut request = oauth
.authorize_url(move || state)
.set_pkce_challenge(challenge);
for scope in &info.scopes {
request = request.add_scope(Scope::new(scope.clone()));
}
let (authorize_url, _) = request.url();
open::that(authorize_url.as_str()).context("Could not open the browser")?;
println!("\nIf your browser did not open, visit:\n{authorize_url}\n");
let returned = tokio::time::timeout(std::time::Duration::from_secs(300), callback)
.await
.map_err(|_| {
anyhow!("Timed out waiting for the browser. Try `exeora login` again.")
})???;
let expected_issuer = Url::parse(&self.gateway)?.origin().ascii_serialization();
if let Some(issuer) = returned.issuer
&& issuer != expected_issuer
{
bail!(
"The authorization came back from {issuer}, not {}. Aborting.",
self.gateway
);
}
let response = self
.http
.post(&info.token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("client_id", info.client_id.as_str()),
("code", returned.code.as_str()),
("redirect_uri", redirect_uri.as_str()),
("code_verifier", verifier.secret()),
])
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let detail = response.text().await.unwrap_or_default();
bail!(
"Token exchange failed ({status}): {}",
detail.chars().take(200).collect::<String>()
);
}
let token: LoginTokenResponse = response.json().await?;
save_credentials(&StoredCredentials {
refresh_token: token.refresh_token,
issuer: expected_issuer,
})?;
let result = LoginResult {
access_token: token.access_token,
expires_at: crate::protocol::now_ms() + token.expires_in * 1000,
};
self.cache_access_token(result.access_token.clone(), result.expires_at)
.await;
Ok(result)
}
}
#[derive(Debug, Deserialize)]
struct RefreshResponse {
access_token: Option<String>,
expires_in: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct LoginTokenResponse {
access_token: String,
refresh_token: String,
expires_in: u64,
}
pub struct LoginResult {
pub access_token: String,
pub expires_at: u64,
}
pub async fn discover_client(http: &reqwest::Client, gateway: &str) -> Result<CliClientInfo> {
let url = Url::parse(gateway)?.join("/oauth/cli-client")?;
let response = http
.get(url)
.send()
.await
.with_context(|| format!("Could not reach the Exeora gateway at {gateway}"))?;
if !response.status().is_success() {
bail!(
"Could not reach the Exeora gateway at {gateway} ({}).",
response.status().as_u16()
);
}
Ok(response.json().await?)
}
pub fn save_credentials(credentials: &StoredCredentials) -> Result<()> {
let serialized = serde_json::to_string(credentials)?;
if let Ok(entry) = Entry::new(SERVICE, ACCOUNT)
&& entry.set_password(&serialized).is_ok()
{
let _ = fs::remove_file(credential_fallback_path()?);
return Ok(());
}
let path = credential_fallback_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
write_secret_file(&path, serialized.as_bytes())?;
Ok(())
}
pub fn load_credentials() -> Result<Option<StoredCredentials>> {
if let Ok(entry) = Entry::new(SERVICE, ACCOUNT)
&& let Ok(value) = entry.get_password()
&& !value.is_empty()
{
return Ok(Some(serde_json::from_str(&value)?));
}
match fs::read(credential_fallback_path()?) {
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
pub fn clear_credentials() -> Result<()> {
if let Ok(entry) = Entry::new(SERVICE, ACCOUNT) {
let _ = entry.delete_credential();
}
match fs::remove_file(credential_fallback_path()?) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
pub fn using_file_fallback() -> bool {
Entry::new(SERVICE, ACCOUNT)
.and_then(|entry| entry.get_password())
.is_err()
}
#[cfg(unix)]
fn write_secret_file(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
Ok(())
}
#[cfg(not(unix))]
fn write_secret_file(path: &Path, bytes: &[u8]) -> Result<()> {
fs::write(path, bytes).map_err(Into::into)
}
#[derive(Clone)]
struct CallbackState {
expected_state: String,
result: Arc<Mutex<Option<oneshot::Sender<Result<CallbackResult>>>>>,
shutdown: Arc<Mutex<Option<oneshot::Sender<()>>>>,
}
struct CallbackResult {
code: String,
issuer: Option<String>,
}
async fn start_loopback(
expected_state: String,
) -> Result<(String, oneshot::Receiver<Result<CallbackResult>>)> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let (result_tx, result_rx) = oneshot::channel();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let state = CallbackState {
expected_state,
result: Arc::new(Mutex::new(Some(result_tx))),
shutdown: Arc::new(Mutex::new(Some(shutdown_tx))),
};
let app = Router::new()
.route("/callback", get(callback))
.with_state(state);
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await;
});
Ok((format!("http://127.0.0.1:{port}/callback"), result_rx))
}
async fn callback(
State(state): State<CallbackState>,
Query(query): Query<HashMap<String, String>>,
) -> (StatusCode, Html<&'static str>) {
let result = if let Some(error) = query.get("error") {
Err(anyhow!("Authorization was declined ({error})."))
} else if query.get("state") != Some(&state.expected_state) {
Err(anyhow!(
"The authorization response did not match this login attempt."
))
} else if let Some(code) = query.get("code") {
Ok(CallbackResult {
code: code.clone(),
issuer: query.get("iss").cloned(),
})
} else {
Err(anyhow!("No authorization code was returned."))
};
let ok = result.is_ok();
if let Some(sender) = state.result.lock().expect("callback result lock").take() {
let _ = sender.send(result);
}
if let Some(sender) = state
.shutdown
.lock()
.expect("callback shutdown lock")
.take()
{
let _ = sender.send(());
}
if ok {
(
StatusCode::OK,
Html(
"<!doctype html><meta charset=utf-8><title>Exeora</title><p>Signed in. You can close this tab and return to the terminal.</p>",
),
)
} else {
(
StatusCode::BAD_REQUEST,
Html(
"<!doctype html><meta charset=utf-8><title>Exeora</title><p>Authorization failed. You can close this tab.</p>",
),
)
}
}