diff --git a/Cargo.lock b/Cargo.lock index 2743117..cd2931a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "async-trait" +version = "0.1.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "auth-git2" version = "0.5.4" @@ -121,6 +138,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -212,6 +238,25 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.3.11" @@ -222,6 +267,16 @@ dependencies = [ "serde", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "5.0.1" @@ -298,15 +353,20 @@ name = "fj" version = "0.0.4" dependencies = [ "auth-git2", + "base64ct", "clap", "directories", "eyre", "forgejo-api", "futures", "git2", + "hyper 1.3.1", + "hyper-util", "open", + "rand", "serde", "serde_json", + "sha256", "soft_assert", "time", "tokio", @@ -452,6 +512,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.14" @@ -495,7 +565,26 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", "indexmap", "slab", "tokio", @@ -521,6 +610,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "0.2.12" @@ -532,6 +627,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -539,10 +645,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.1.0", +] + [[package]] name = "httparse" version = "1.8.0" @@ -565,9 +681,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -579,6 +695,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.5", + "http 1.1.0", + "http-body 1.0.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -586,12 +722,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.28", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.3.1", + "pin-project-lite", + "tokio", +] + [[package]] name = "idna" version = "0.5.0" @@ -962,6 +1113,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro2" version = "1.0.81" @@ -980,6 +1137,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.5.1" @@ -1011,10 +1198,10 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", - "hyper", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.28", "hyper-tls", "ipnet", "js-sys", @@ -1156,6 +1343,30 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha256" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18278f6a914fa3070aa316493f7d2ddfb9ac86ebc06fa3b83bffda487e9065b0" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1413,6 +1624,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + [[package]] name = "unicase" version = "2.7.0" diff --git a/Cargo.toml b/Cargo.toml index ca36436..241ec37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,15 +7,20 @@ edition = "2021" [dependencies] auth-git2 = "0.5.3" +base64ct = { version = "1.6.0", features = ["std"] } clap = { version = "4.3.11", features = ["derive"] } directories = "5.0.1" eyre = "0.6.8" forgejo-api = "0.3.0" futures = "0.3.28" git2 = "0.17.2" +hyper = "1.3.1" +hyper-util = { version = "0.1.5", features = ["tokio", "server", "http1", "http2"] } open = "5.0.0" +rand = "0.8.5" serde = { version = "1.0.170", features = ["derive"] } serde_json = "1.0.100" +sha256 = "1.5.0" soft_assert = "0.1.1" time = { version = "0.3.30", features = ["formatting", "macros"] } tokio = { version = "1.29.1", features = ["full"] } diff --git a/src/auth.rs b/src/auth.rs index 1511fe8..c184b5b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,4 +1,5 @@ use clap::Subcommand; +use eyre::OptionExt; #[derive(Subcommand, Clone, Debug)] pub enum AuthCommand { @@ -18,12 +19,28 @@ pub enum AuthCommand { } impl AuthCommand { - pub async fn run(self, keys: &mut crate::KeyInfo) -> eyre::Result<()> { + pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { match self { AuthCommand::Login => { - todo!(); - // let user = readline("username: ").await?; - // let pass = readline("password: ").await?; + let repo_info = crate::repo::RepoInfo::get_current(host_name, None, None)?; + let host_url = repo_info.host_url(); + let client_info = get_client_info_for(host_url); + if let Some((client_id, _)) = client_info { + oauth_login(keys, host_url, client_id).await?; + } else { + let host_domain = host_url.host_str().ok_or_eyre("invalid host")?; + let host_path = host_url.path(); + let mut applications_url = host_url.clone(); + applications_url + .path_segments_mut() + .map_err(|_| eyre::eyre!("invalid url"))? + .extend(["user", "settings", "applications"]); + + println!("{host_domain}{host_path} doesn't support easy login"); + println!(); + println!("Please visit {applications_url}"); + println!("to create a token, and use it to log in with `fj auth add-token`"); + } } AuthCommand::Logout { host } => { let info_opt = keys.hosts.remove(&host); @@ -64,10 +81,151 @@ impl AuthCommand { } pub fn get_client_info_for(url: &url::Url) -> Option<(&'static str, &'static str)> { - let host = url.host_str()?; let client_info = match (url.host_str()?, url.path()) { ("codeberg.org", "/") => option_env!("CLIENT_INFO_CODEBERG"), _ => None, }; client_info.and_then(|info| info.split_once(":")) } + +async fn oauth_login( + keys: &mut crate::KeyInfo, + host: &url::Url, + client_id: &'static str, +) -> eyre::Result<()> { + use base64ct::Encoding; + use rand::{distributions::Alphanumeric, prelude::*}; + + let mut rng = thread_rng(); + + let state = (0..32) + .map(|_| rng.sample(Alphanumeric) as char) + .collect::(); + let code_verifier = (0..43) + .map(|_| rng.sample(Alphanumeric) as char) + .collect::(); + let code_challenge = + base64ct::Base64Url::encode_string(sha256::digest(&code_verifier).as_bytes()); + + let mut auth_url = host.clone(); + auth_url + .path_segments_mut() + .map_err(|_| eyre::eyre!("invalid url"))? + .extend(["login", "oauth", "authorize"]); + auth_url.query_pairs_mut().extend_pairs([ + ("client_id", client_id), + ("redirect_uri", "http://127.0.0.1:26218/"), + ("response_type", "code"), + ("code_challenge_method", "S256"), + ("code_challenge", &code_challenge), + ("state", &state), + ]); + open::that(auth_url.as_str()).unwrap(); + + let (handle, mut rx) = auth_server(); + let res = rx.recv().await.unwrap(); + handle.abort(); + let code = match res { + Ok(Some((code, returned_state))) => { + if returned_state == state { + code + } else { + eyre::bail!("returned with invalid state"); + } + } + Ok(None) => { + println!("Login canceled"); + return Ok(()); + } + Err(e) => { + eyre::bail!("Failed to authenticate: {e}"); + } + }; + + let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, host.clone())?; + let request = forgejo_api::structs::OAuthTokenRequest::Public { + client_id, + code_verifier: &code_verifier, + code: &code, + redirect_uri: url::Url::parse("http://127.0.0.1:26218/").unwrap(), + }; + let response = api.oauth_get_access_token(request).await?; + + let api = forgejo_api::Forgejo::new( + forgejo_api::Auth::OAuth2(&response.access_token), + host.clone(), + )?; + let current_user = api.user_get_current().await?; + let name = current_user + .login + .ok_or_eyre("user does not have login name")?; + + // A minute less, in case any weirdness happens at the exact moment it + // expires. Better to refresh slightly too soon than slightly too late. + let expires_in = std::time::Duration::from_secs(response.expires_in.saturating_sub(60) as u64); + let expires_at = time::OffsetDateTime::now_utc() + expires_in; + let login_info = crate::keys::LoginInfo::OAuth { + name, + token: response.access_token, + refresh_token: response.refresh_token, + expires_at, + }; + keys.hosts + .insert(host.host_str().unwrap().to_string(), login_info); + + Ok(()) +} + +use tokio::{sync::mpsc::Receiver, task::JoinHandle}; + +fn auth_server() -> ( + JoinHandle>, + Receiver, String>>, +) { + let addr: std::net::SocketAddr = ([127, 0, 0, 1], 26218).into(); + let (tx, rx) = tokio::sync::mpsc::channel(1); + let tx = std::sync::Arc::new(tx); + let handle = tokio::spawn(async move { + let listener = tokio::net::TcpListener::bind(addr).await?; + let server = + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()); + let svc = hyper::service::service_fn(|req: hyper::Request| { + let tx = std::sync::Arc::clone(&tx); + async move { + let mut code = None; + let mut state = None; + let mut error_description = None; + if let Some(query) = req.uri().query() { + for item in query.split("&") { + let (key, value) = item.split_once("=").unwrap_or((item, "")); + match key { + "code" => code = Some(value), + "state" => state = Some(value), + "error_description" => error_description = Some(value), + _ => eprintln!("unknown key {key} {value}"), + } + } + } + let (response, message) = match (code, state, error_description) { + (_, _, Some(error)) => (Err(error.to_owned()), "Failed to authenticate"), + (Some(code), Some(state), None) => ( + Ok(Some((code.to_owned(), state.to_owned()))), + "Authenticated! Close this tab and head back to your terminal", + ), + _ => (Ok(None), "Canceled"), + }; + tx.send(response).await.unwrap(); + Ok::<_, hyper::Error>(hyper::Response::new(message.to_owned())) + } + }); + loop { + let (connection, _addr) = listener.accept().await.unwrap(); + server + .serve_connection(hyper_util::rt::TokioIo::new(connection), svc) + .await + .unwrap(); + } + Ok(()) + }); + (handle, rx) +} diff --git a/src/main.rs b/src/main.rs index d24f4c4..1cc81e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,7 +68,7 @@ async fn main() -> eyre::Result<()> { println!("currently signed in to {name}@{host}{}", url.path()); } } - Command::Auth(subcommand) => subcommand.run(&mut keys).await?, + Command::Auth(subcommand) => subcommand.run(&mut keys, host_name).await?, Command::Release(subcommand) => subcommand.run(&mut keys, host_name).await?, }