crates/gunnar-cli/tests/login.rs
//! **`gunnar login`: the CLI's browser device flow** (5.4 §3b passkey-cli,
//! owner 2026-10-10: "CLI uses a FIDO2 sk-ssh key or a `gunnar login` browser
//! device flow").
//!
//! The CLI mints its OWN ed25519 key, asks the console
//! (`POST /auth/device/start`), shows the person the code and the page, and
//! polls. The person types the code at the console and touches their passkey;
//! gunnar enrols the CLI's key on them, expiring. The private half never
//! leaves this machine: it is written 0600 beside its `.pub`, and the CLI
//! keeps it only when the console's answer names THAT key.
//!
//! The console here is a fake speaking the console's JSON over plain HTTP on
//! loopback.
#![cfg(feature = "control")]
use std::io::{BufRead as _, BufReader, Read as _, Write as _};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Verdict {
Approve,
Deny,
/// Approve, naming a key that is not the one the CLI sent.
ApproveAnother,
}
#[derive(Default)]
struct Heard {
public_key: Option<String>,
label: Option<String>,
ttl_secs: Option<u64>,
polls: u32,
}
fn fingerprint(line: &str) -> String {
ssh_key::PublicKey::from_openssh(line.trim())
.expect("an OpenSSH key")
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string()
}
/// A console on loopback that approves (or not) on the second poll.
fn fake_console(verdict: Verdict) -> (String, Arc<Mutex<Heard>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("a port");
let url = format!("http://{}", listener.local_addr().expect("an address"));
let heard = Arc::new(Mutex::new(Heard::default()));
let seen = heard.clone();
let origin = url.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { return };
let mut reader = BufReader::new(stream);
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
continue;
}
let path = line
.split_whitespace()
.nth(1)
.unwrap_or_default()
.to_owned();
let mut length = 0usize;
loop {
let mut header = String::new();
reader.read_line(&mut header).expect("a header");
if header.trim().is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
length = value.trim().parse().unwrap_or(0);
}
}
}
let mut body = vec![0u8; length];
reader.read_exact(&mut body).expect("a body");
let body: serde_json::Value =
serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let answer = {
let mut heard = seen.lock().expect("heard");
match path.as_str() {
"/auth/device/start" => {
heard.public_key = body["public_key"].as_str().map(str::to_owned);
heard.label = body["label"].as_str().map(str::to_owned);
heard.ttl_secs = body["ttl_secs"].as_u64();
serde_json::json!({
"device_code": "d".repeat(64),
"user_code": "BCDF-GHJK",
"verification_uri": format!("{origin}/login/device"),
"expires_in": 30,
"interval": 0,
})
}
"/auth/device/poll" => {
assert_eq!(
body["device_code"],
"d".repeat(64),
"the CLI polls with the device code"
);
heard.polls += 1;
if heard.polls < 2 {
serde_json::json!({ "state": "pending" })
} else {
match verdict {
Verdict::Deny => serde_json::json!({ "state": "denied" }),
Verdict::Approve | Verdict::ApproveAnother => {
let fp = match verdict {
Verdict::Approve => {
fingerprint(heard.public_key.as_deref().expect("a key"))
}
_ => "SHA256:somebody-elses-key".to_owned(),
};
serde_json::json!({
"state": "approved",
"principal": "anna",
"fingerprint": fp,
"expires_at_unix_ms": 4_000_000_000_000i64,
})
}
}
}
}
_ => serde_json::Value::Null,
}
};
let text = answer.to_string();
let status = if answer.is_null() {
"404 Not Found"
} else {
"200 OK"
};
let mut stream = reader.into_inner();
let _ = write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
text.len()
);
}
});
(url, heard)
}
fn options(console: &str, dir: &std::path::Path) -> gunnar_cli::login::Login {
gunnar_cli::login::Login {
console: console.to_owned(),
ttl_hours: 8,
label: Some("anna's laptop".to_owned()),
dir: dir.to_path_buf(),
}
}
#[test]
fn gunnar_login_keeps_its_own_key_once_the_console_names_it() {
let (console, heard) = fake_console(Verdict::Approve);
let dir = tempfile::tempdir().expect("a dir");
let mut told = Vec::new();
let logged = gunnar_cli::login::login(&options(&console, dir.path()), &mut told)
.expect("the login completes");
let told = String::from_utf8(told).expect("utf-8");
assert!(
told.contains("BCDF-GHJK"),
"the person is shown the code: {told}"
);
assert!(
told.contains(&format!("{console}/login/device")),
"and where to type it: {told}"
);
let heard = heard.lock().expect("heard");
let sent = heard.public_key.clone().expect("the CLI sent a key");
assert!(sent.starts_with("ssh-ed25519 "), "an ed25519 key: {sent}");
assert_eq!(heard.label.as_deref(), Some("anna's laptop"));
assert_eq!(heard.ttl_secs, Some(8 * 3600));
assert_eq!(logged.principal, "anna");
assert_eq!(logged.fingerprint, fingerprint(&sent));
let private = ssh_key::PrivateKey::read_openssh_file(&logged.key_path)
.expect("the private key was written");
assert_eq!(
private
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
logged.fingerprint,
"the key on disk is the key enrolled"
);
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&logged.key_path)
.expect("a file")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "the private key is the owner's alone");
}
let public =
std::fs::read_to_string(logged.key_path.with_extension("pub")).expect("a .pub beside it");
assert_eq!(fingerprint(&public), logged.fingerprint);
let meta: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(logged.key_path.with_extension("json"))
.expect("a .json beside it"),
)
.expect("json");
assert_eq!(meta["principal"], "anna");
assert_eq!(meta["expires_at_unix_ms"], 4_000_000_000_000i64);
assert_eq!(meta["console"], console);
}
#[test]
fn a_denied_login_keeps_no_key() {
let (console, _heard) = fake_console(Verdict::Deny);
let dir = tempfile::tempdir().expect("a dir");
let why = gunnar_cli::login::login(&options(&console, dir.path()), &mut Vec::new())
.expect_err("denied");
assert!(why.to_string().contains("denied"), "{why}");
assert_eq!(
std::fs::read_dir(dir.path()).expect("the dir").count(),
0,
"no key is kept for a login nobody approved"
);
}
#[test]
fn an_approval_for_another_key_is_not_this_login() {
let (console, _heard) = fake_console(Verdict::ApproveAnother);
let dir = tempfile::tempdir().expect("a dir");
let why = gunnar_cli::login::login(&options(&console, dir.path()), &mut Vec::new())
.expect_err("another key");
assert!(why.to_string().contains("key"), "{why}");
assert_eq!(std::fs::read_dir(dir.path()).expect("the dir").count(), 0);
}
#[test]
fn a_console_is_reached_over_https_unless_it_is_this_machine() {
let dir = tempfile::tempdir().expect("a dir");
let why = gunnar_cli::login::login(
&options("http://console.example", dir.path()),
&mut Vec::new(),
)
.expect_err("plain http");
assert!(why.to_string().contains("https"), "{why}");
}
#[test]
fn gunnar_login_is_a_verb() {
use clap::Parser as _;
let cli = gunnar_common::cli::Cli::try_parse_from([
"gunnar",
"login",
"https://console.example",
"--ttl",
"4",
])
.expect("parses");
match cli.command {
gunnar_common::cli::Command::Login(args) => {
assert_eq!(args.console, "https://console.example");
assert_eq!(args.ttl, 4);
}
other => panic!("not a login: {other:?}"),
}
}