use std::process::ExitCode;
use std::time::Duration;
use crate::managed_server::ResolvedServer;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(not(test))]
const POLL_INTERVAL: Duration = Duration::from_secs(3);
#[cfg(test)]
const POLL_INTERVAL: Duration = Duration::from_millis(20);
pub async fn selected_server(force_managed: bool) -> Result<Option<ResolvedServer>, String> {
if force_managed {
return Ok(None);
}
if !has_selection() {
return Ok(crate::managed_server::discovered_local_router().await);
}
crate::managed_server::resolve(None, None, None, false)
.await
.map(Some)
.map_err(|error| format!("the selected server is not usable: {error}"))
}
pub async fn target_for(
local: bool,
managed: bool,
server: Option<&str>,
) -> Result<Option<ResolvedServer>, String> {
if local || managed {
return Ok(None);
}
if let Some(server) = server {
return crate::managed_server::resolve(Some(server), None, None, false)
.await
.map(Some)
.map_err(|error| format!("{server} is not usable: {error}"));
}
selected_server(managed).await
}
fn has_selection() -> bool {
if std::env::var_os("LINK_ASSISTANT_ROUTER_URL").is_some_and(|value| !value.is_empty())
|| std::env::var_os("ROUTER_URL").is_some_and(|value| !value.is_empty())
{
return true;
}
crate::managed_server::load_persisted()
.ok()
.flatten()
.is_some()
}
pub async fn authorize(
server: &ResolvedServer,
provider: &str,
mode: Option<&str>,
code: Option<String>,
) -> ExitCode {
match authorize_inner(server, provider, mode, code).await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(1)
}
}
}
async fn authorize_inner(
server: &ResolvedServer,
provider: &str,
mode: Option<&str>,
code: Option<String>,
) -> Result<(), String> {
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|error| format!("could not build an HTTP client: {error}"))?;
let mut body = serde_json::json!({ "provider": provider });
if let Some(mode) = mode {
body["mode"] = serde_json::Value::String(mode.to_string());
}
let begun: serde_json::Value = send(
&client,
server,
reqwest::Method::POST,
"/api/login",
Some(body),
)
.await?;
let login_id = begun
.get("login_id")
.and_then(serde_json::Value::as_str)
.ok_or("the router did not return a login id")?
.to_string();
println!("Authorizing {provider} on {}", server.base_url);
if let Some(url) = begun.get("url").and_then(serde_json::Value::as_str) {
println!("Open this URL:\n{url}");
}
if let Some(user_code) = begun.get("user_code").and_then(serde_json::Value::as_str) {
println!("Enter this code: {user_code}");
}
if status_of(&begun) == "authorized" {
finish(provider, server);
return Ok(());
}
if status_of(&begun) == "awaiting_device" {
return poll_until_authorized(&client, server, &login_id, provider).await;
}
let submitted = match code {
Some(code) => code,
None => read_code().await?,
};
let submitted = submitted.trim();
if submitted.is_empty() {
return Err(format!(
"no authorization code was supplied; the pending login is still open on the router — \
finish it with `router auth {provider} --flow code --code <CODE>`"
));
}
let completed: serde_json::Value = send(
&client,
server,
reqwest::Method::POST,
&format!("/api/login/{login_id}/code"),
Some(serde_json::json!({ "code": submitted })),
)
.await?;
if status_of(&completed) != "authorized" {
return Err(format!(
"the router did not accept the code: it reports `{}`",
status_of(&completed)
));
}
finish(provider, server);
Ok(())
}
async fn poll_until_authorized(
client: &reqwest::Client,
server: &ResolvedServer,
login_id: &str,
provider: &str,
) -> Result<(), String> {
let deadline = std::time::Instant::now() + Duration::from_secs(10 * 60);
while std::time::Instant::now() < deadline {
tokio::time::sleep(POLL_INTERVAL).await;
let view: serde_json::Value = send(
client,
server,
reqwest::Method::GET,
&format!("/api/login/{login_id}"),
None,
)
.await?;
match status_of(&view) {
"authorized" => {
finish(provider, server);
return Ok(());
}
"failed" | "expired" | "cancelled" => {
return Err(format!(
"the login ended as `{}` on the router",
status_of(&view)
));
}
_ => {}
}
}
Err("the login was not approved in time".to_string())
}
fn finish(provider: &str, server: &ResolvedServer) {
println!(
"{provider} authorization saved on {} ({})",
server.base_url, server.source
);
}
fn status_of(view: &serde_json::Value) -> &str {
view.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
}
pub async fn status(server: &ResolvedServer) -> ExitCode {
let client = match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() {
Ok(client) => client,
Err(error) => {
eprintln!("error: could not build an HTTP client: {error}");
return ExitCode::from(1);
}
};
match send::<serde_json::Value>(&client, server, reqwest::Method::GET, "/v1/accounts", None)
.await
{
Ok(body) => {
println!("server: {} ({})", server.base_url, server.source);
report_credentials(&body);
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(1)
}
}
}
pub async fn credential_home(server: &ResolvedServer, provider: &str) -> Option<String> {
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.ok()?;
let body: serde_json::Value = send(&client, server, reqwest::Method::GET, "/v1/accounts", None)
.await
.ok()?;
home_in_accounts(&body, provider)
}
pub async fn accounts(server: &ResolvedServer) -> ExitCode {
match get(server, "/v1/accounts").await {
Ok(body) => {
println!("server: {} ({})", server.base_url, server.source);
report_credentials(&body);
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(1)
}
}
}
pub async fn get(server: &ResolvedServer, path: &str) -> Result<serde_json::Value, String> {
let client = http_client()?;
send(&client, server, reqwest::Method::GET, path, None).await
}
pub async fn post(
server: &ResolvedServer,
path: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let client = http_client()?;
send(&client, server, reqwest::Method::POST, path, Some(body)).await
}
pub async fn delete(server: &ResolvedServer, path: &str) -> Result<serde_json::Value, String> {
let client = http_client()?;
send(&client, server, reqwest::Method::DELETE, path, None).await
}
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|error| format!("could not build an HTTP client: {error}"))
}
#[must_use]
pub fn remote_import_refusal(base_url: &str, home: Option<&str>) -> Vec<String> {
let mut lines = vec![format!(
"error: import installs a credential into the credential home of the machine running \
it, so it cannot provision {base_url} from here."
)];
if let Some(home) = home {
lines.push(format!("note: {base_url} reads its credential from {home}"));
}
lines.push(String::from(
"note: that deployment accepts no credential over HTTP, so run `router auth import` \
there, or authorize it from here with `router auth claude` / `router auth codex`, \
which do act on the selected server.",
));
lines.push(String::from(
"note: pass --local to import into this machine's credential home.",
));
lines
}
#[must_use]
pub fn home_in_accounts(body: &serde_json::Value, provider: &str) -> Option<String> {
["credentials", "accounts"]
.into_iter()
.filter_map(|key| body.get(key).and_then(serde_json::Value::as_array))
.flatten()
.find(|entry| {
entry
.get("name")
.and_then(serde_json::Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case(provider))
})
.and_then(|entry| entry.get("home").and_then(serde_json::Value::as_str))
.map(str::to_owned)
}
async fn send<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
server: &ResolvedServer,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<T, String> {
let url = format!("{}{path}", server.base_url.trim_end_matches('/'));
let mut request = client.request(method, &url);
if let Some(token) = server.token.as_deref() {
request = request.bearer_auth(token);
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|error| format!("could not reach {url}: {error}"))?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(format!(
"the selected router refused an administrator credential ({status}). Re-select it \
with an admin token: `router server use {} --token-stdin`",
server.base_url
));
}
return Err(format!("{url} returned {status}: {}", text.trim()));
}
serde_json::from_str(&text)
.map_err(|error| format!("could not read the reply from {url}: {error}"))
}
async fn read_code() -> Result<String, String> {
use std::io::BufRead as _;
println!("Paste authorization code:");
tokio::task::spawn_blocking(|| {
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.map(|_| line)
.map_err(|error| format!("could not read authorization code: {error}"))
})
.await
.map_err(|error| format!("authorization prompt failed: {error}"))?
}
#[cfg(test)]
#[path = "auth_remote_tests.rs"]
mod tests;
fn report_credentials(body: &serde_json::Value) {
for line in credential_report(body) {
println!("{line}");
}
}
fn credential_report(body: &serde_json::Value) -> Vec<String> {
let rows = |key: &str| {
body.get(key)
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
};
let accounts = rows("accounts");
let entries = if accounts.is_empty() {
rows("credentials")
} else {
accounts
};
if !entries.is_empty() {
let mut lines = vec![crate::accounts_cli::header()];
lines.extend(entries.iter().map(|entry| {
let text = |key: &str| {
entry
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or("-")
};
let number = |key: &str| entry.get(key).and_then(serde_json::Value::as_u64);
crate::accounts_cli::row(&crate::accounts_cli::AccountRow {
name: text("name"),
healthy: entry.get("healthy").and_then(serde_json::Value::as_bool),
credential: text("credential"),
used: number("used"),
limit: number("request_limit"),
remaining: number("remaining_requests"),
home: text("home").to_string(),
})
}));
return lines;
}
if let Some(note) = body.get("note").and_then(serde_json::Value::as_str) {
return vec![note.to_string()];
}
vec!["no accounts are configured on this router".to_string()]
}