use std::time::Duration;
use serde::Deserialize;
use crate::config::Config;
const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
pub fn resolve_base_url(url: Option<&str>, cfg: &Config) -> String {
if let Some(u) = url {
return u.trim().trim_end_matches('/').to_string();
}
if let Some(pu) = cfg.server.public_url.as_deref() {
return pu.trim().trim_end_matches('/').to_string();
}
format!("http://127.0.0.1:{}", cfg.server.port)
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceAuthResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
#[serde(default)]
pub verification_uri_complete: Option<String>,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollOutcome {
Approved(String),
Denied,
Expired,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollSignal {
Pending,
SlowDown,
Terminal(PollOutcome),
}
pub trait DeviceFlow {
fn request_device_code(&self, label: Option<&str>) -> Result<DeviceAuthResponse, String>;
fn poll_token(&self, device_code: &str) -> Result<PollSignal, String>;
fn revoke(&self, token: &str) -> Result<(), String>;
}
pub fn poll_backoff(base_interval: u64, slow_downs: u32) -> u64 {
base_interval + 5 * (slow_downs as u64)
}
pub fn poll_loop<F, S>(
flow: &F,
device_code: &str,
interval: u64,
deadline_secs: u64,
mut sleep: S,
) -> Result<PollOutcome, String>
where
F: DeviceFlow,
S: FnMut(Duration),
{
let start = std::time::Instant::now();
let mut slow_downs: u32 = 0;
loop {
if start.elapsed().as_secs() >= deadline_secs {
return Ok(PollOutcome::Expired);
}
match flow.poll_token(device_code)? {
PollSignal::Terminal(outcome) => return Ok(outcome),
PollSignal::Pending => {
sleep(Duration::from_secs(poll_backoff(interval, slow_downs)));
}
PollSignal::SlowDown => {
slow_downs += 1;
sleep(Duration::from_secs(poll_backoff(interval, slow_downs)));
}
}
}
}
pub fn non_interactive_json(resp: &DeviceAuthResponse, base: &str) -> serde_json::Value {
serde_json::json!({
"verification_uri": resp.verification_uri,
"verification_uri_complete": resp.verification_uri_complete,
"user_code": resp.user_code,
"device_code": resp.device_code,
"interval": resp.interval,
"expires_in": resp.expires_in,
"next_step": format!("lific login --complete {} --url {}", resp.device_code, base),
})
}
pub struct HttpDeviceFlow {
base: String,
client: reqwest::blocking::Client,
}
impl HttpDeviceFlow {
pub fn new(base: &str) -> Result<Self, String> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
Ok(Self {
base: base.trim_end_matches('/').to_string(),
client,
})
}
}
impl DeviceFlow for HttpDeviceFlow {
fn request_device_code(&self, label: Option<&str>) -> Result<DeviceAuthResponse, String> {
let url = format!("{}/oauth/device_authorization", self.base);
let mut form: Vec<(&str, &str)> = Vec::new();
if let Some(l) = label {
form.push(("client_name", l));
}
let resp = self
.client
.post(&url)
.form(&form)
.send()
.map_err(|e| format!("device authorization request failed: {e}"))?;
if !resp.status().is_success() {
let code = resp.status().as_u16();
let body = resp.text().unwrap_or_default();
return Err(format!("device authorization failed (HTTP {code}): {body}"));
}
resp.json::<DeviceAuthResponse>()
.map_err(|e| format!("invalid device authorization response: {e}"))
}
fn poll_token(&self, device_code: &str) -> Result<PollSignal, String> {
let url = format!("{}/oauth/token", self.base);
let form = [
("grant_type", DEVICE_CODE_GRANT),
("device_code", device_code),
];
let resp = self
.client
.post(&url)
.form(&form)
.send()
.map_err(|e| format!("token poll failed: {e}"))?;
let status = resp.status();
let body: serde_json::Value = resp.json().unwrap_or(serde_json::json!({}));
if status.is_success() {
let token = body
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "token response missing access_token".to_string())?;
return Ok(PollSignal::Terminal(PollOutcome::Approved(token.to_string())));
}
let err = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
Ok(classify_poll_error(err))
}
fn revoke(&self, token: &str) -> Result<(), String> {
let url = format!("{}/oauth/revoke", self.base);
let form = [("token", token)];
let _ = self
.client
.post(&url)
.bearer_auth(token)
.form(&form)
.send()
.map_err(|e| format!("revoke request failed: {e}"))?;
Ok(())
}
}
pub fn classify_poll_error(error: &str) -> PollSignal {
match error {
"authorization_pending" => PollSignal::Pending,
"slow_down" => PollSignal::SlowDown,
"access_denied" => PollSignal::Terminal(PollOutcome::Denied),
"expired_token" => PollSignal::Terminal(PollOutcome::Expired),
_ => PollSignal::Terminal(PollOutcome::Expired),
}
}
pub struct LoginArgs {
pub url: Option<String>,
pub non_interactive: bool,
pub complete: Option<String>,
pub label: Option<String>,
pub no_store: bool,
}
pub fn run_login(args: &LoginArgs, cfg: &Config, json: bool) -> Result<(), String> {
let base = resolve_base_url(args.url.as_deref(), cfg);
let flow = HttpDeviceFlow::new(&base)?;
run_login_with_flow(args, &base, &flow, json, crate::cli::term::stdin_is_tty())
}
pub fn run_login_with_flow<F: DeviceFlow>(
args: &LoginArgs,
base: &str,
flow: &F,
json: bool,
stdin_tty: bool,
) -> Result<(), String> {
if let Some(device_code) = &args.complete {
let outcome = poll_loop(flow, device_code, 5, 900, std::thread::sleep)?;
return finish(args, base, outcome, json);
}
let resp = flow.request_device_code(args.label.as_deref())?;
if args.non_interactive || !stdin_tty {
let payload = non_interactive_json(&resp, base);
println!("{}", serde_json::to_string_pretty(&payload).unwrap_or_default());
return Ok(());
}
let complete = resp
.verification_uri_complete
.clone()
.unwrap_or_else(|| resp.verification_uri.clone());
use crate::cli::ui;
ui::intro("lific login");
ui::note(
"To sign in, enter this code",
format!(
"{}\n\nat {}\nor open directly: {}",
resp.user_code, resp.verification_uri, complete
),
);
let spinner = cliclack::spinner();
spinner.start("Waiting for approval on the other device…");
let outcome = poll_loop(
flow,
&resp.device_code,
resp.interval.max(1),
resp.expires_in,
std::thread::sleep,
);
match &outcome {
Ok(PollOutcome::Approved(_)) => spinner.stop("Approved"),
Ok(PollOutcome::Denied) => spinner.error("Denied"),
Ok(PollOutcome::Expired) => spinner.error("Expired"),
Err(_) => spinner.error("Failed"),
}
finish(args, base, outcome?, json)
}
fn finish(args: &LoginArgs, base: &str, outcome: PollOutcome, json: bool) -> Result<(), String> {
match outcome {
PollOutcome::Approved(token) => {
if args.no_store {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "approved",
"stored": false,
"access_token": token,
}))
.unwrap_or_default()
);
} else {
crate::cli::ui::note("Approved. Token (not stored)", &token);
crate::cli::ui::outro("Done");
}
return Ok(());
}
crate::cli::credentials::store(base, &token)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "approved",
"stored": true,
"url": base,
}))
.unwrap_or_default()
);
} else {
crate::cli::ui::outro(format!("Signed in to {base}. Token stored."));
}
Ok(())
}
PollOutcome::Denied => Err("login denied by user".to_string()),
PollOutcome::Expired => {
Err("login timed out / device code expired — run `lific login` again".to_string())
}
}
}
pub fn run_logout(url: Option<&str>, cfg: &Config, json: bool) -> Result<(), String> {
let base = resolve_base_url(url, cfg);
let existing = crate::cli::credentials::load(&base);
if let Some(token) = &existing
&& let Ok(flow) = HttpDeviceFlow::new(&base)
{
let _ = flow.revoke(token);
}
let removed = crate::cli::credentials::delete(&base);
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"url": base,
"removed": removed,
}))
.unwrap_or_default()
);
} else if removed {
crate::cli::ui::step(format!("Signed out of {base}."));
} else {
crate::cli::ui::info(format!("No stored credential for {base}."));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[test]
fn backoff_adds_5s_per_slow_down() {
assert_eq!(poll_backoff(5, 0), 5);
assert_eq!(poll_backoff(5, 1), 10);
assert_eq!(poll_backoff(5, 3), 20);
assert_eq!(poll_backoff(2, 2), 12);
}
#[test]
fn classify_maps_rfc8628_errors() {
assert_eq!(classify_poll_error("authorization_pending"), PollSignal::Pending);
assert_eq!(classify_poll_error("slow_down"), PollSignal::SlowDown);
assert_eq!(
classify_poll_error("access_denied"),
PollSignal::Terminal(PollOutcome::Denied)
);
assert_eq!(
classify_poll_error("expired_token"),
PollSignal::Terminal(PollOutcome::Expired)
);
assert_eq!(
classify_poll_error("invalid_grant"),
PollSignal::Terminal(PollOutcome::Expired)
);
}
#[test]
fn resolve_base_url_precedence() {
let mut cfg = Config::default();
cfg.server.port = 3999;
assert_eq!(
resolve_base_url(Some("http://h:1/"), &cfg),
"http://h:1"
);
cfg.server.public_url = Some("https://lific.example/".into());
assert_eq!(resolve_base_url(None, &cfg), "https://lific.example");
cfg.server.public_url = None;
assert_eq!(resolve_base_url(None, &cfg), "http://127.0.0.1:3999");
}
#[test]
fn non_interactive_json_shape() {
let resp = DeviceAuthResponse {
device_code: "DEV123".into(),
user_code: "BCDF-GHJK".into(),
verification_uri: "http://h/oauth/device".into(),
verification_uri_complete: Some("http://h/oauth/device?user_code=BCDF-GHJK".into()),
expires_in: 900,
interval: 5,
};
let v = non_interactive_json(&resp, "http://h");
assert_eq!(v["user_code"], "BCDF-GHJK");
assert_eq!(v["device_code"], "DEV123");
assert_eq!(v["interval"], 5);
assert_eq!(v["expires_in"], 900);
assert_eq!(
v["verification_uri_complete"],
"http://h/oauth/device?user_code=BCDF-GHJK"
);
assert_eq!(
v["next_step"],
"lific login --complete DEV123 --url http://h"
);
}
struct FakeFlow {
signals: RefCell<Vec<PollSignal>>,
polls: RefCell<u32>,
device: DeviceAuthResponse,
}
impl FakeFlow {
fn new(signals: Vec<PollSignal>) -> Self {
Self {
signals: RefCell::new(signals),
polls: RefCell::new(0),
device: DeviceAuthResponse {
device_code: "DEV".into(),
user_code: "BCDF-GHJK".into(),
verification_uri: "http://h/oauth/device".into(),
verification_uri_complete: Some("http://h/oauth/device?user_code=BCDF-GHJK".into()),
expires_in: 900,
interval: 5,
},
}
}
}
impl DeviceFlow for FakeFlow {
fn request_device_code(&self, _label: Option<&str>) -> Result<DeviceAuthResponse, String> {
Ok(self.device.clone())
}
fn poll_token(&self, _device_code: &str) -> Result<PollSignal, String> {
*self.polls.borrow_mut() += 1;
let mut sigs = self.signals.borrow_mut();
if sigs.is_empty() {
Ok(PollSignal::Terminal(PollOutcome::Expired))
} else {
Ok(sigs.remove(0))
}
}
fn revoke(&self, _token: &str) -> Result<(), String> {
Ok(())
}
}
#[test]
fn poll_loop_pending_then_approved() {
let flow = FakeFlow::new(vec![
PollSignal::Pending,
PollSignal::Pending,
PollSignal::Terminal(PollOutcome::Approved("lific_at_xyz".into())),
]);
let sleeps = RefCell::new(0u32);
let outcome = poll_loop(&flow, "DEV", 5, 900, |_| {
*sleeps.borrow_mut() += 1;
})
.unwrap();
assert_eq!(outcome, PollOutcome::Approved("lific_at_xyz".into()));
assert_eq!(*sleeps.borrow(), 2);
assert_eq!(*flow.polls.borrow(), 3);
}
#[test]
fn poll_loop_slow_down_increases_backoff() {
let flow = FakeFlow::new(vec![
PollSignal::SlowDown,
PollSignal::SlowDown,
PollSignal::Terminal(PollOutcome::Approved("t".into())),
]);
let durations = RefCell::new(Vec::<u64>::new());
let outcome = poll_loop(&flow, "DEV", 5, 900, |d| {
durations.borrow_mut().push(d.as_secs());
})
.unwrap();
assert_eq!(outcome, PollOutcome::Approved("t".into()));
assert_eq!(*durations.borrow(), vec![10, 15]);
}
#[test]
fn poll_loop_denied_is_terminal() {
let flow = FakeFlow::new(vec![
PollSignal::Pending,
PollSignal::Terminal(PollOutcome::Denied),
]);
let outcome = poll_loop(&flow, "DEV", 5, 900, |_| {}).unwrap();
assert_eq!(outcome, PollOutcome::Denied);
}
#[test]
fn poll_loop_honors_deadline() {
let flow = FakeFlow::new(vec![PollSignal::Pending; 3]);
let outcome = poll_loop(&flow, "DEV", 5, 0, |_| {}).unwrap();
assert_eq!(outcome, PollOutcome::Expired);
}
#[test]
fn non_interactive_prints_json_and_exits_without_polling() {
let flow = FakeFlow::new(vec![]); let args = LoginArgs {
url: None,
non_interactive: true,
complete: None,
label: None,
no_store: true,
};
run_login_with_flow(&args, "http://h", &flow, true, true).unwrap();
assert_eq!(*flow.polls.borrow(), 0, "must not poll in non-interactive mode");
}
#[test]
fn no_tty_forces_non_interactive() {
let flow = FakeFlow::new(vec![]);
let args = LoginArgs {
url: None,
non_interactive: false,
complete: None,
label: None,
no_store: true,
};
run_login_with_flow(&args, "http://h", &flow, true, false).unwrap();
assert_eq!(*flow.polls.borrow(), 0);
}
}