use super::{
api_commands::api_request,
args::CliOptions,
errors::{Result, agent_error},
utils::{
encode_component, number_alias, open_url, optional_string_alias, optional_string_field,
progress,
},
};
use reqwest::Method;
use serde_json::Value;
use std::{
thread,
time::{Duration, Instant},
};
mod keychain;
mod output;
mod payload;
mod token_store;
use output::{logged_in_message, print_login_started, print_login_success};
use token_store::{read_stored_token, write_session_token};
pub(crate) fn read_or_login_token(cli: &CliOptions) -> Result<String> {
if let Some(token) = read_stored_token(cli)? {
return Ok(token);
}
login_and_store(cli).map(|session| session.token)
}
pub(crate) fn login(cli: &CliOptions) -> Result<()> {
if !cli.token.trim().is_empty() {
write_session_token(cli.token.trim())?;
print_login_success(cli, "saved", None)?;
return Ok(());
}
let session = login_and_store(cli)?;
print_login_success(cli, "logged_in", session.email.as_deref())?;
Ok(())
}
struct StoredSession {
token: String,
email: Option<String>,
}
#[derive(Clone, Copy)]
struct LoginTiming {
expires_seconds: u64,
interval_seconds: u64,
}
fn login_and_store(cli: &CliOptions) -> Result<StoredSession> {
let start = api_request(cli, Method::POST, "/v1/login/device", None, None)?;
let Some(login_url) = optional_string_alias(&start, &["loginUrl", "login_url"]) else {
return missing_login_field(cli, "browser URL");
};
let user_code = optional_string_alias(&start, &["userCode", "user_code"]);
let Some(device_code) = optional_string_alias(&start, &["deviceCode", "device_code"]) else {
return missing_login_field(cli, "device code");
};
let timing = login_timing(cli, &start)?;
open_url(&login_url);
print_login_started(
cli,
&start,
&login_url,
user_code.as_deref(),
timing.expires_seconds,
timing.interval_seconds,
)?;
let session = poll_login(cli, device_code.as_str(), timing)?;
let Some(token) = optional_string_field(&session, "token") else {
return Err(agent_error(
"login_failed",
"Tovuk login did not return a session token.",
"Run `tovuk login` again and complete the browser login.",
cli.output.json,
));
};
let email = optional_string_field(&session, "email");
write_session_token(&token)?;
progress(cli, &logged_in_message(&session));
Ok(StoredSession { token, email })
}
fn login_timing(cli: &CliOptions, start: &Value) -> Result<LoginTiming> {
Ok(LoginTiming {
expires_seconds: required_positive_number_alias(
cli,
start,
&["expiresInSeconds", "expires_in_seconds"],
"login expiry seconds",
)?,
interval_seconds: required_positive_number_alias(
cli,
start,
&["intervalSeconds", "interval_seconds"],
"login poll interval seconds",
)?,
})
}
fn required_positive_number_alias(
cli: &CliOptions,
value: &Value,
aliases: &[&str],
field: &str,
) -> Result<u64> {
match number_alias(value, aliases).filter(|value| *value > 0) {
Some(value) => Ok(value),
None => Err(agent_error(
"login_failed",
format!("Tovuk login did not return valid {field}."),
"Retry `tovuk login`. If it keeps failing, check Tovuk status.",
cli.output.json,
)),
}
}
fn poll_login(cli: &CliOptions, device_code: &str, timing: LoginTiming) -> Result<Value> {
let mut interval_seconds = timing.interval_seconds;
let deadline = Instant::now() + Duration::from_secs(timing.expires_seconds);
while Instant::now() < deadline {
thread::sleep(Duration::from_secs(interval_seconds));
let response = api_request(
cli,
Method::GET,
&format!("/v1/login/device/{}", encode_component(device_code)),
None,
None,
)?;
match optional_string_field(&response, "status").as_deref() {
Some("complete") => return Ok(response),
Some("expired") => return login_expired(cli),
_ => {}
}
if let Some(next_interval) =
number_alias(&response, &["intervalSeconds", "interval_seconds"])
.filter(|value| *value > 0)
{
interval_seconds = next_interval;
}
}
login_expired(cli)
}
fn missing_login_field(cli: &CliOptions, field: &str) -> Result<StoredSession> {
Err(agent_error(
"login_failed",
format!("Tovuk login did not return a {field}."),
"Retry `tovuk login`. If it keeps failing, check Tovuk status.",
cli.output.json,
))
}
fn login_expired(cli: &CliOptions) -> Result<Value> {
Err(agent_error(
"login_expired",
"Tovuk login expired before it completed.",
"Run `tovuk login` again and finish the browser login in the newly opened tab.",
cli.output.json,
))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{CliOptions, required_positive_number_alias};
#[test]
fn login_timing_requires_positive_number() {
let cli = CliOptions::default();
let message = required_positive_number_alias(
&cli,
&json!({"intervalSeconds": 0}),
&["intervalSeconds", "interval_seconds"],
"login poll interval seconds",
)
.err()
.map(|error| error.to_string());
assert_eq!(
message.as_deref(),
Some("Tovuk login did not return valid login poll interval seconds.")
);
}
#[test]
fn login_timing_accepts_aliases() {
let value = required_positive_number_alias(
&CliOptions::default(),
&json!({"expires_in_seconds": 900}),
&["expiresInSeconds", "expires_in_seconds"],
"login expiry seconds",
);
assert_eq!(value.ok(), Some(900));
}
}