//! Our own small JSON API under `/api/v1`. Authentication is by personal access token only: //! `Authorization: Bearer forge_...` or `Authorization: token forge_...`. Session cookies are never //! read here, so a browser cannot drive these routes and CSRF protection does not apply. Errors are //! `{"error": "..."}` with a matching status code. Anonymous callers get 401 whenever they cannot //! use a repo, the same as git, so missing and hidden repos look identical to them. pub mod statuses; use std::net::IpAddr; use std::path::PathBuf; use std::time::Instant; use forge_core::{Action, Denied, Repo, User, authorize, token::hash_token}; use forge_git::browse; use http::HeaderMap; use http::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue, RETRY_AFTER, WWW_AUTHENTICATE}; use serde_json::{Value, json}; use topcoat::router::StatusCode; use topcoat::router::response::Response; use crate::state::AppState; use crate::throttle::{self, GIT_IP_LIMIT}; /// The scope a token needs to use this API. It is the only scope the token form offers. pub const REPO_SCOPE: &str = "repo"; /// A failed step. The response is boxed, as in git_http, to keep `Result` small. pub type Refusal = Box; /// Builds a JSON response with the given status. pub fn json(status: StatusCode, value: &Value) -> Response { let mut res = Response::new(topcoat::router::Body::from(value.to_string())); *res.status_mut() = status; res.headers_mut().insert( CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8"), ); res } /// A JSON error: `{"error": message}`. pub fn error(status: StatusCode, message: &str) -> Response { json(status, &json!({ "error": message })) } /// 401 with a Bearer challenge. pub fn unauthorized() -> Response { let mut res = error(StatusCode::UNAUTHORIZED, "authentication required"); res.headers_mut().insert( WWW_AUTHENTICATE, HeaderValue::from_static("Bearer realm=\"forge\""), ); res } /// A 500 that does not reveal the cause; the cause is logged. pub fn internal(message: impl std::fmt::Display) -> Refusal { tracing::error!(error = %message, "api request failed"); Box::new(error( StatusCode::INTERNAL_SERVER_ERROR, "internal server error", )) } /// Pulls the token out of `Bearer ` or `token `. The scheme is matched /// case-insensitively. Returns `None` for any other shape, including a missing header. fn presented_token(headers: &HeaderMap) -> Option> { let value = headers.get(AUTHORIZATION)?.to_str().ok()?; let Some((scheme, rest)) = value.split_once(' ') else { return Some(None); }; if !(scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("token")) { return Some(None); } let token = rest.trim(); if token.is_empty() || !token.starts_with("forge_") { return Some(None); } Some(Some(token.to_owned())) } /// Resolves the caller from the `Authorization` header. /// /// - No header: `Ok(None)`, an anonymous caller. /// - A header that is not a forge token, an unknown or expired token, or a token whose user is /// gone: 401, and the failure counts against the client IP. The budget is the git one, so /// guesses cannot be split between git and the API. /// - A token without the `repo` scope: 403, which does not count as a failed login. /// - Otherwise the token's `last_used_at` is touched and the user is returned. pub async fn authenticate( state: &AppState, ip: IpAddr, headers: &HeaderMap, ) -> Result, Refusal> { if !headers.contains_key(AUTHORIZATION) { return Ok(None); } let key = throttle::ip_key("git-ip", ip); let now = Instant::now(); if let Some(retry) = state.throttle.check(&key, GIT_IP_LIMIT, now) { tracing::warn!(ip = %ip, retry_after_secs = retry.as_secs(), "api authentication throttled"); let mut res = error( StatusCode::TOO_MANY_REQUESTS, "too many failed attempts, try again later", ); res.headers_mut() .insert(RETRY_AFTER, HeaderValue::from(retry.as_secs().max(1))); return Err(Box::new(res)); } let Some(Some(token)) = presented_token(headers) else { state.throttle.record_failure(&key, now); return Err(Box::new(unauthorized())); }; let access = match state.store.token_by_hash(&hash_token(&token)).await { Ok(Some(access)) => access, Ok(None) => { state.throttle.record_failure(&key, now); return Err(Box::new(unauthorized())); } Err(e) => return Err(internal(e)), }; let user = match state.store.user_by_id(&access.user_id).await { Ok(Some(user)) if !user.is_org() => user, Ok(_) => { state.throttle.record_failure(&key, now); return Err(Box::new(unauthorized())); } Err(e) => return Err(internal(e)), }; if !access.scopes.iter().any(|scope| scope == REPO_SCOPE) { return Err(Box::new(error( StatusCode::FORBIDDEN, "the token lacks the repo scope", ))); } if let Err(e) = state.store.touch_token(&access.id).await { return Err(internal(e)); } Ok(Some(user)) } /// Maps a refused action. Anonymous callers always get 401, whether the repo is hidden, missing, /// or merely read-only to them. Signed-in callers get 404 for hidden repos and 403 otherwise. fn refused(actor: Option<&User>, denied: Denied) -> Refusal { Box::new(match (actor, denied) { (None, _) => unauthorized(), (Some(_), Denied::NotFound) => error(StatusCode::NOT_FOUND, "repository not found"), (Some(_), Denied::Forbidden) => error(StatusCode::FORBIDDEN, "forbidden"), }) } /// Loads `owner/name` and checks that `actor` may perform `action` on it. pub async fn load_repo( state: &AppState, actor: Option<&User>, owner: &str, name: &str, action: Action, ) -> Result { let repo = match state.store.repo_by_owner_and_name(owner, name).await { Ok(Some(repo)) => repo, Ok(None) => return Err(refused(actor, Denied::NotFound)), Err(e) => return Err(internal(e)), }; let (role, org_role) = match actor { Some(user) => ( state .store .collaborator_role(&repo.id, &user.id) .await .map_err(internal)?, state .store .org_repo_role(&repo.owner_id, &user.id, &repo.id) .await .map_err(internal)?, ), None => (None, None), }; authorize(actor, &repo, role, org_role, action).map_err(|denied| refused(actor, denied))?; Ok(repo) } /// On-disk path of a repo's bare directory. pub fn repo_dir(state: &AppState, repo: &Repo) -> Result { forge_git::repo::repo_path(&state.config.data_dir, &repo.id).map_err(internal) } /// True when `sha` names a commit in the repo. Runs the git read off the async runtime. pub async fn commit_exists(path: PathBuf, sha: String) -> Result { let found = tokio::task::spawn_blocking(move || browse::read_commit(&path, &sha)) .await .map_err(internal)? .map_err(internal)?; Ok(found.is_some()) } /// Lowercases a full commit id. `None` unless it is 40 or 64 hex characters. pub fn normalize_sha(raw: &str) -> Option { let lower = raw.to_ascii_lowercase(); forge_core::store::commit_status::is_full_sha(&lower).then_some(lower) } /// Reads the request body, refusing anything over `limit` bytes. pub async fn read_body(body: topcoat::router::Body, limit: usize) -> Result, Refusal> { topcoat::router::to_bytes(body, limit) .await .map(|bytes| bytes.to_vec()) .map_err(|_| { Box::new(error( StatusCode::PAYLOAD_TOO_LARGE, "request body is too large", )) }) } #[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 } #[test] fn accepts_bearer_and_token_schemes() { assert_eq!( presented_token(&headers_with("Bearer forge_abc")), Some(Some("forge_abc".into())) ); assert_eq!( presented_token(&headers_with("token forge_abc")), Some(Some("forge_abc".into())) ); assert_eq!( presented_token(&headers_with("BEARER forge_abc")), Some(Some("forge_abc".into())) ); } #[test] fn rejects_other_shapes() { assert_eq!( presented_token(&headers_with("Basic Zm9vOmJhcg==")), Some(None) ); assert_eq!( presented_token(&headers_with("Bearer notforge")), Some(None) ); assert_eq!(presented_token(&headers_with("Bearer ")), Some(None)); assert_eq!(presented_token(&HeaderMap::new()), None); } #[test] fn sha_is_lowercased_and_must_be_full() { assert_eq!(normalize_sha(&"AB".repeat(20)), Some("ab".repeat(20))); assert_eq!(normalize_sha("abc123"), None); assert_eq!(normalize_sha(&"z".repeat(40)), None); } }