//! Basic-auth parsing and personal-access-token resolution for git over smart HTTP. //! Only personal access tokens are accepted as the Basic password; account passwords are never //! accepted over git HTTP. Tokens are never logged. use base64::prelude::{BASE64_STANDARD, Engine}; use forge_core::{User, token::hash_token}; use http::{HeaderMap, header::AUTHORIZATION}; use crate::access::AccessError; use crate::state::AppState; /// Usernames that stand in for "no particular user" when a token is the password. const TOKEN_ONLY_USERNAMES: [&str; 2] = ["x-access-token", "oauth2"]; /// Parses `Authorization: Basic ` into (username, password). /// The scheme is matched case-insensitively. Anything else returns `None`. pub fn parse_basic_auth(headers: &HeaderMap) -> Option<(String, String)> { let value = headers.get(AUTHORIZATION)?.to_str().ok()?; let (scheme, encoded) = value.split_once(' ')?; if !scheme.eq_ignore_ascii_case("basic") || encoded.is_empty() { return None; } let decoded = BASE64_STANDARD.decode(encoded).ok()?; let text = String::from_utf8(decoded).ok()?; let (username, password) = text.split_once(':')?; Some((username.to_owned(), password.to_owned())) } /// Resolves the actor from a personal access token sent as the Basic password. /// /// - No `Authorization` header: `Ok(None)` (anonymous). /// - A header that is not usable Basic auth, an unknown or expired token, a token whose user is /// gone, or a username that matches neither the user nor a token-only name: `Err(Unauthorized)`. /// - Otherwise the token's `last_used_at` is touched and the user is returned. pub async fn user_from_basic( state: &AppState, headers: &HeaderMap, ) -> Result, AccessError> { if !headers.contains_key(AUTHORIZATION) { return Ok(None); } let Some((username, token)) = parse_basic_auth(headers) else { return Err(AccessError::Unauthorized); }; let token_hash = hash_token(&token); let access = state .store .token_by_hash(&token_hash) .await .map_err(internal)? .ok_or(AccessError::Unauthorized)?; // An organization never authenticates, so a token that names one is refused. let user = state .store .user_by_id(&access.user_id) .await .map_err(internal)? .filter(|user| !user.is_org()) .ok_or(AccessError::Unauthorized)?; let username_ok = username.eq_ignore_ascii_case(&user.username) || TOKEN_ONLY_USERNAMES .iter() .any(|name| username.eq_ignore_ascii_case(name)); if !username_ok { return Err(AccessError::Unauthorized); } state .store .touch_token(&access.id) .await .map_err(internal)?; Ok(Some(user)) } fn internal(e: forge_core::Error) -> AccessError { AccessError::Internal(e.to_string()) } #[cfg(test)] mod tests { use super::*; use http::HeaderValue; fn headers_with(value: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, HeaderValue::from_str(value).expect("valid header"), ); headers } fn basic(plain: &str) -> String { format!("Basic {}", BASE64_STANDARD.encode(plain)) } #[test] fn parses_user_and_token() { let headers = headers_with(&basic("alice:forge_abc")); assert_eq!( parse_basic_auth(&headers), Some(("alice".to_owned(), "forge_abc".to_owned())) ); } #[test] fn scheme_is_case_insensitive() { let value = format!("bAsIc {}", BASE64_STANDARD.encode("alice:t")); let headers = headers_with(&value); assert_eq!( parse_basic_auth(&headers), Some(("alice".into(), "t".into())) ); } #[test] fn password_may_contain_colons() { let headers = headers_with(&basic("alice:a:b:c")); assert_eq!( parse_basic_auth(&headers), Some(("alice".into(), "a:b:c".into())) ); } #[test] fn empty_password_is_parsed_but_user_is_not_resolved_here() { let headers = headers_with(&basic("alice:")); assert_eq!( parse_basic_auth(&headers), Some(("alice".into(), String::new())) ); } #[test] fn rejects_wrong_scheme() { let value = format!("Bearer {}", BASE64_STANDARD.encode("alice:t")); assert_eq!(parse_basic_auth(&headers_with(&value)), None); } #[test] fn rejects_missing_colon() { assert_eq!(parse_basic_auth(&headers_with(&basic("alicetoken"))), None); } #[test] fn rejects_bad_base64() { assert_eq!( parse_basic_auth(&headers_with("Basic !!!not-base64!!!")), None ); } #[test] fn rejects_extra_spaces_and_empty_value() { let encoded = BASE64_STANDARD.encode("alice:t"); assert_eq!( parse_basic_auth(&headers_with(&format!("Basic {encoded}"))), None ); assert_eq!(parse_basic_auth(&headers_with("Basic ")), None); assert_eq!(parse_basic_auth(&HeaderMap::new()), None); } #[test] fn rejects_non_utf8_credentials() { let encoded = BASE64_STANDARD.encode([0xff_u8, 0xfe, b':', b'x']); assert_eq!( parse_basic_auth(&headers_with(&format!("Basic {encoded}"))), None ); } }