diff --git a/Cargo.lock b/Cargo.lock index ba933cb..7360368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,12 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -169,6 +175,7 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" name = "forgejo-api" version = "0.1.0" dependencies = [ + "base64ct", "bytes", "eyre", "reqwest", @@ -179,6 +186,7 @@ dependencies = [ "time", "tokio", "url", + "zeroize", ] [[package]] @@ -1185,3 +1193,9 @@ dependencies = [ "cfg-if", "windows-sys", ] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index 83ad83c..c53d74d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ serde = { version = "1.0.168", features = ["derive"] } time = { version = "0.3.22", features = ["parsing", "serde", "formatting"] } serde_json = "1.0.108" bytes = "1.5.0" +base64ct = "1.6.0" +zeroize = "1.7.0" [dev-dependencies] eyre = "0.6.9" diff --git a/src/lib.rs b/src/lib.rs index e548128..1776812 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ use reqwest::{Client, Request, StatusCode}; use serde::{de::DeserializeOwned, Serialize}; use soft_assert::*; use url::Url; +use zeroize::Zeroize; pub struct Forgejo { url: Url, @@ -42,29 +43,90 @@ pub enum ForgejoError { UnexpectedStatusCode(StatusCode), #[error("{} {}: {}", .0.as_u16(), .0.canonical_reason().unwrap_or(""), .1)] ApiError(StatusCode, String), + #[error("the provided authorization was too long to accept")] + AuthTooLong, +} + +/// Method of authentication to connect to the Forgejo host with. +pub enum Auth<'a> { + /// Application Access Token. Grants access to scope enabled for the + /// provided token, which may include full access. + /// + /// To learn how to create a token, see + /// [the Codeberg docs on the subject](https://docs.codeberg.org/advanced/access-token/). + /// + /// To learn about token scope, see + /// [the official Forgejo docs](https://forgejo.org/docs/latest/user/token-scope/). + Token(&'a str), + /// Username, password, and 2-factor auth code (if enabled). Grants full + /// access to the user's account. + Password { + username: &'a str, + password: &'a str, + mfa: Option<&'a str>, + }, + /// No authentication. Only grants access to access public endpoints. + None, } impl Forgejo { - pub fn new(api_key: &str, url: Url) -> Result { - Self::with_user_agent(api_key, url, "forgejo-api-rs") + pub fn new(auth: Auth, url: Url) -> Result { + Self::with_user_agent(auth, url, "forgejo-api-rs") } - pub fn with_user_agent( - api_key: &str, - url: Url, - user_agent: &str, - ) -> Result { + pub fn with_user_agent(auth: Auth, url: Url, user_agent: &str) -> Result { soft_assert!( matches!(url.scheme(), "http" | "https"), Err(ForgejoError::HttpRequired) ); let mut headers = reqwest::header::HeaderMap::new(); - let mut key_header: reqwest::header::HeaderValue = format!("token {api_key}") - .try_into() - .map_err(|_| ForgejoError::KeyNotAscii)?; - key_header.set_sensitive(true); - headers.insert("Authorization", key_header); + match auth { + Auth::Token(token) => { + let mut header: reqwest::header::HeaderValue = format!("token {token}") + .try_into() + .map_err(|_| ForgejoError::KeyNotAscii)?; + header.set_sensitive(true); + headers.insert("Authorization", header); + } + Auth::Password { + username, + password, + mfa, + } => { + let len = (((username.len() + password.len() + 1) + .checked_mul(4) + .ok_or(ForgejoError::AuthTooLong)?) + / 3) + + 1; + let mut bytes = vec![0; len]; + + // panic safety: len cannot be zero + let mut encoder = base64ct::Encoder::::new(&mut bytes).unwrap(); + + // panic safety: len will always be enough + encoder.encode(username.as_bytes()).unwrap(); + encoder.encode(b":").unwrap(); + encoder.encode(password.as_bytes()).unwrap(); + + let b64 = encoder.finish().unwrap(); + + let mut header: reqwest::header::HeaderValue = + format!("Basic {b64}").try_into().unwrap(); // panic safety: base64 is always ascii + header.set_sensitive(true); + headers.insert("Authorization", header); + + bytes.zeroize(); + + if let Some(mfa) = mfa { + let mut key_header: reqwest::header::HeaderValue = + mfa.try_into().map_err(|_| ForgejoError::KeyNotAscii)?; + key_header.set_sensitive(true); + headers.insert("X-FORGEJO-OTP", key_header); + } + } + Auth::None => (), + } let client = Client::builder() .user_agent(user_agent) .default_headers(headers)