use base64::Engine;
use rand::Rng;
use sha2::{Digest, Sha256};
use url::Url;
use crate::config::{self, Env};
pub async fn login(base_url: &str, env: Env, profile_name: &str) -> anyhow::Result<()> {
let prime_url = base_url.trim_end_matches('/');
let verifier = generate_pkce_verifier();
let challenge = pkce_challenge(&verifier);
let server = tiny_http::Server::http("127.0.0.1:0")
.map_err(|e| anyhow::anyhow!("Failed to bind: {e}"))?;
let port = server.server_addr().to_ip().unwrap().port();
let redirect_uri = format!("http://127.0.0.1:{port}/callback");
let state = generate_state();
let client_id = register_client(prime_url, &redirect_uri).await?;
let auth_url = format!(
"{prime_url}/oauth/authorize/google?client_id={client_id}&response_type=code&redirect_uri={redirect_uri}&code_challenge={challenge}&code_challenge_method=S256&state={state}"
);
eprintln!("Opening browser for login...");
if open::that(&auth_url).is_err() {
eprintln!("Could not open browser. Please visit:\n{auth_url}");
}
eprintln!("Waiting for authentication...");
let (code, returned_state) = wait_for_callback(&server)?;
if returned_state != state {
anyhow::bail!("OAuth state mismatch — possible CSRF attack");
}
let token_response =
exchange_token(prime_url, &code, &verifier, &redirect_uri, &client_id).await?;
let access_token = token_response["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No access_token in response"))?;
let token = if token_response.get("prime_account").is_some() {
let pa = &token_response["prime_account"];
eprintln!(
"Logged in as {} ({})",
pa["name"].as_str().unwrap_or(""),
pa["external_id"].as_str().unwrap_or("")
);
access_token.to_string()
} else if let Some(accounts) = token_response.get("accounts") {
let accounts = accounts
.as_array()
.ok_or_else(|| anyhow::anyhow!("Expected accounts array"))?;
if accounts.is_empty() {
anyhow::bail!("No Prime Accounts found for this user");
}
if accounts.len() == 1 {
let pa_id = accounts[0]["external_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing external_id"))?;
let selected = select_account(prime_url, access_token, pa_id).await?;
let token = selected["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No access_token after account selection"))?;
let pa = &selected["prime_account"];
eprintln!(
"Logged in as {} ({})",
pa["name"].as_str().unwrap_or(""),
pa["external_id"].as_str().unwrap_or("")
);
token.to_string()
} else {
eprintln!("\nMultiple Prime Accounts found:");
for (i, acct) in accounts.iter().enumerate() {
eprintln!(
" [{}] {} ({})",
i + 1,
acct["name"].as_str().unwrap_or("?"),
acct["external_id"].as_str().unwrap_or("?")
);
}
eprint!("Select account [1]: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let choice: usize = input.trim().parse().unwrap_or(1);
let idx = choice.saturating_sub(1).min(accounts.len() - 1);
let pa_id = accounts[idx]["external_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing external_id"))?;
let selected = select_account(prime_url, access_token, pa_id).await?;
let token = selected["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No access_token after account selection"))?;
let pa = &selected["prime_account"];
eprintln!(
"Logged in as {} ({})",
pa["name"].as_str().unwrap_or(""),
pa["external_id"].as_str().unwrap_or("")
);
token.to_string()
}
} else {
access_token.to_string()
};
let mut profile = config::load_profile(env, profile_name).unwrap_or_else(|| config::Profile {
query_key: None,
key_source: "none".into(),
key_label: None,
key_path: None,
p256_public_key: String::new(),
sub_org_id: String::new(),
ethereum_signer_address: String::new(),
account_external_id: String::new(),
});
profile.query_key = Some(token);
config::save_profile(env, profile_name, &profile)?;
eprintln!("Token saved to profile '{profile_name}'");
Ok(())
}
async fn register_client(prime_url: &str, redirect_uri: &str) -> anyhow::Result<String> {
let http = reqwest::Client::new();
let res = http
.post(format!("{prime_url}/oauth/register"))
.json(&serde_json::json!({
"client_name": "Legend CLI",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}))
.send()
.await?;
let body: serde_json::Value = res.json().await?;
body["client_id"]
.as_str()
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("No client_id in registration response: {body}"))
}
async fn exchange_token(
prime_url: &str,
code: &str,
verifier: &str,
redirect_uri: &str,
client_id: &str,
) -> anyhow::Result<serde_json::Value> {
let http = reqwest::Client::new();
let res = http
.post(format!("{prime_url}/oauth/token"))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("code_verifier", verifier),
("redirect_uri", redirect_uri),
("client_id", client_id),
])
.send()
.await?;
if !res.status().is_success() {
let body = res.text().await?;
anyhow::bail!("Token exchange failed: {body}");
}
Ok(res.json().await?)
}
async fn select_account(
prime_url: &str,
unscoped_token: &str,
pa_external_id: &str,
) -> anyhow::Result<serde_json::Value> {
let http = reqwest::Client::new();
let res = http
.post(format!("{prime_url}/dashboard/auth/select-account"))
.header("Authorization", format!("Bearer {unscoped_token}"))
.json(&serde_json::json!({
"prime_account_id": pa_external_id
}))
.send()
.await?;
if !res.status().is_success() {
let body = res.text().await?;
anyhow::bail!("Account selection failed: {body}");
}
Ok(res.json().await?)
}
fn wait_for_callback(server: &tiny_http::Server) -> anyhow::Result<(String, String)> {
let request = server
.recv_timeout(std::time::Duration::from_secs(300))
.map_err(|e| anyhow::anyhow!("Failed to receive callback: {e}"))?
.ok_or_else(|| anyhow::anyhow!("Timed out waiting for login callback"))?;
let url = Url::parse(&format!("http://localhost{}", request.url()))?;
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
let code = params
.get("code")
.ok_or_else(|| {
let error = params
.get("error")
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown".into());
anyhow::anyhow!("Login failed: {error}")
})?
.to_string();
let state = params
.get("state")
.map(|s| s.to_string())
.unwrap_or_default();
let response = tiny_http::Response::from_string(
"<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>",
)
.with_header(
"Content-Type: text/html"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
Ok((code, state))
}
fn generate_pkce_verifier() -> String {
let mut rng = rand::thread_rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.r#gen()).collect();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}
fn pkce_challenge(verifier: &str) -> String {
let hash = Sha256::digest(verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
}
fn generate_state() -> String {
let mut rng = rand::thread_rng();
let bytes: Vec<u8> = (0..16).map(|_| rng.r#gen()).collect();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}