Menu
Public repository / d63e826eb482cb963477774b04fe8a84f23f0e45
AkurAI-Drive / client/src/auth.rs
Latest change feb7ac86b5e60d55b429eacb7146cf0dc32c6628 - Add native Linux sync client (akurai-drive-sync) by olibuijr
//! Device-token login: spin up a loopback HTTP server on a random free port,
//! open the browser to the backend's `/auth/device` endpoint, and capture the
//! `?token=` the backend redirects back with.
use anyhow::{bail, Result};
use std::time::{Duration, Instant};
const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
/// Run the full login handshake and return the captured device token.
pub fn login(api_base: &str) -> Result<String> {
// Bind to port 0 → the OS picks a free loopback port.
let server = tiny_http::Server::http("127.0.0.1:0")
.map_err(|e| anyhow::anyhow!("starting loopback server: {e}"))?;
let port = match server.server_addr() {
tiny_http::ListenAddr::IP(addr) => addr.port(),
other => bail!("unexpected listen address: {other:?}"),
};
let redirect = format!("http://127.0.0.1:{port}/cb");
let auth_url = format!("{api_base}/auth/device?redirect={}", urlencode(&redirect));
println!("Opening your browser to log in...");
println!("If it doesn't open, visit:\n {auth_url}");
open_browser(&auth_url);
// Wait for the backend's redirect to hit our /cb endpoint.
let deadline = Instant::now() + LOGIN_TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
bail!("login timed out after {}s", LOGIN_TIMEOUT.as_secs());
}
match server.recv_timeout(remaining.min(Duration::from_secs(1))) {
Ok(Some(request)) => {
if let Some(token) = token_from_path(request.url()) {
respond_ok(request);
return Ok(token);
}
// Some other path (favicon, etc.) — answer and keep waiting.
respond_wait(request);
}
Ok(None) => continue, // timeout tick; re-check deadline
Err(e) => bail!("loopback server error: {e}"),
}
}
}
/// Extract the `token` query parameter from a request path like
/// `/cb?token=abc123`.
fn token_from_path(path: &str) -> Option<String> {
let query = path.split_once('?').map(|(_, q)| q)?;
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
if k == "token" {
let decoded = urldecode(v);
if !decoded.is_empty() {
return Some(decoded);
}
}
}
}
None
}
fn respond_ok(request: tiny_http::Request) {
let html = "<!doctype html><html><head><meta charset=utf-8>\
<title>AkurAI Drive</title></head><body style=\"font-family:system-ui;\
text-align:center;padding-top:4rem;\"><h2>Logged in to AkurAI Drive</h2>\
<p>You can close this tab and return to the terminal.</p></body></html>";
let resp = tiny_http::Response::from_string(html).with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
.unwrap(),
);
let _ = request.respond(resp);
}
fn respond_wait(request: tiny_http::Request) {
let resp = tiny_http::Response::from_string("waiting for token...");
let _ = request.respond(resp);
}
/// Try the `open` crate; fall back to `xdg-open`.
fn open_browser(url: &str) {
if open::that(url).is_ok() {
return;
}
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
/// Minimal percent-encoding for the redirect URL we embed in the query string.
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Minimal percent-decoding for the captured token value.
fn urldecode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
let hi = hexval(bytes[i + 1]);
let lo = hexval(bytes[i + 2]);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn hexval(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_token() {
assert_eq!(token_from_path("/cb?token=abc123"), Some("abc123".into()));
assert_eq!(
token_from_path("/cb?foo=1&token=xy%2Dz&bar=2"),
Some("xy-z".into())
);
}
#[test]
fn no_token_is_none() {
assert_eq!(token_from_path("/cb"), None);
assert_eq!(token_from_path("/favicon.ico"), None);
assert_eq!(token_from_path("/cb?token="), None);
}
#[test]
fn roundtrip_encoding() {
let original = "http://127.0.0.1:54321/cb";
assert_eq!(urldecode(&urlencode(original)), original);
}
}