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() -> Result<Option<ResolvedServer>, String> {
if !has_selection() {
return Ok(None);
}
crate::managed_server::resolve(None, None, None)
.await
.map(Some)
.map_err(|error| format!("the selected server is not usable: {error}"))
}
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 begun.get("url").is_none() && begun.get("user_code").is_some() {
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);
let accounts = body
.get("accounts")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
if accounts.is_empty() {
println!("no accounts are configured on this router");
return ExitCode::SUCCESS;
}
for account in accounts {
println!(
"{:<16} {:<10} {}",
account
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("-"),
account
.get("credential")
.and_then(serde_json::Value::as_str)
.unwrap_or("-"),
account
.get("home")
.and_then(serde_json::Value::as_str)
.unwrap_or("-"),
);
}
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("error: {error}");
ExitCode::from(1)
}
}
}
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;