diff --git a/README.md b/README.md index 63569d28..6ec25ee3 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Authorization: Bearer my-secret-token If `BITVAULT_API_KEY` is unset, the API requires no authentication. Consider setting it in any deployment where the data should not be publicly readable. -When `BITVAULT_BASIC_AUTH_USERNAME`/`BITVAULT_BASIC_AUTH_PASSWORD` (web UI Basic Auth) is configured, it also applies to all `/api/v1/` routes **except `/health`**. API clients must send Basic Auth credentials alongside the `Authorization: Bearer` token when both are configured. +When `BITVAULT_API_KEY` is set, the bearer token is the only credential for `/api/v1/` — web UI Basic Auth (`BITVAULT_BASIC_AUTH_USERNAME`/`BITVAULT_BASIC_AUTH_PASSWORD`) is not applied to the API, so clients send just `Authorization: Bearer `. When no API key is set but Basic Auth is configured, Basic Auth guards all `/api/v1/` routes **except `/health`** instead. ### Paste IDs diff --git a/openapi.yaml b/openapi.yaml index cc2ffa34..c8bbc19f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9,13 +9,15 @@ info: ## Authentication - Two independent auth layers may be active on a deployment: + Exactly one credential guards `/api/v1` (except `/api/v1/health`, always open): - 1. **HTTP Basic Auth** — wraps the entire `/api/v1` scope (except `/api/v1/health`). - Controlled by `BITVAULT_BASIC_AUTH_USERNAME` / `BITVAULT_BASIC_AUTH_PASSWORD`. + 1. **Bearer token** — if `BITVAULT_API_KEY` is set, it is the only credential + for `/api/v1`. Send as `Authorization: Bearer `. Basic Auth is not + applied to the API in this case, even if configured for the web UI. - 2. **Bearer token** — per-request API key checked inside each handler. - Controlled by `BITVAULT_API_KEY`. Send as `Authorization: Bearer `. + 2. **HTTP Basic Auth** — if no API key is set but + `BITVAULT_BASIC_AUTH_USERNAME` / `BITVAULT_BASIC_AUTH_PASSWORD` are, Basic + credentials are required on `/api/v1`. When neither is configured the API is open (a warning is logged at startup). @@ -223,8 +225,9 @@ components: scheme: basic description: | HTTP Basic Auth set via `BITVAULT_BASIC_AUTH_USERNAME` / - `BITVAULT_BASIC_AUTH_PASSWORD`. Wraps the entire `/api/v1` scope - when configured. + `BITVAULT_BASIC_AUTH_PASSWORD`. Guards `/api/v1` only when + `BITVAULT_API_KEY` is not set; otherwise the bearer token is the + sole API credential. parameters: pasteId: diff --git a/src/endpoints/api.rs b/src/endpoints/api.rs index d52b4235..99721cbb 100644 --- a/src/endpoints/api.rs +++ b/src/endpoints/api.rs @@ -672,4 +672,75 @@ mod tests { assert!(check_api_key(None, &req).is_ok()); assert!(check_api_key(Some(" "), &req).is_ok()); } + + /// Mirrors the /api/v1 wiring in main.rs with explicit config instead of ARGS. + macro_rules! auth_app { + ($api_key:expr, $basic_username:expr) => {{ + use actix_web::middleware::Condition; + use actix_web_httpauth::middleware::HttpAuthentication; + let api_key: Option<&'static str> = $api_key; + test::init_service( + App::new().service( + web::scope("/api/v1") + .wrap(Condition::new( + crate::util::auth::api_scope_uses_basic_auth(api_key, $basic_username), + HttpAuthentication::basic(crate::util::auth::api_auth_validator), + )) + .route( + "/paste", + web::post().to(move |req: HttpRequest| async move { + match check_api_key(api_key, &req) { + Ok(()) => HttpResponse::Ok().finish(), + Err(e) => *e, + } + }), + ), + ), + ) + .await + }}; + } + + #[actix_web::test] + async fn api_key_replaces_basic_auth_on_api_scope() { + let app = auth_app!(Some("s3cret"), Some("admin")); + + let req = test::TestRequest::post() + .uri("/api/v1/paste") + .insert_header(("Authorization", "Bearer s3cret")) + .to_request(); + assert_eq!(test::call_service(&app, req).await.status(), StatusCode::OK); + + for auth in [None, Some("Bearer wrong"), Some("Basic YWRtaW46cHc=")] { + let mut req = test::TestRequest::post().uri("/api/v1/paste"); + if let Some(auth) = auth { + req = req.insert_header(("Authorization", auth)); + } + let resp = test::call_service(&app, req.to_request()).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{auth:?}"); + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["code"], "API_KEY_REQUIRED", "{auth:?}"); + } + } + + #[actix_web::test] + async fn basic_auth_guards_api_scope_without_api_key() { + let app = auth_app!(None, Some("admin")); + + let req = test::TestRequest::post().uri("/api/v1/paste").to_request(); + assert_eq!( + test::call_service(&app, req).await.status(), + StatusCode::UNAUTHORIZED + ); + + assert!(!crate::util::auth::api_scope_uses_basic_auth(None, None)); + assert!(!crate::util::auth::api_scope_uses_basic_auth( + None, + Some(" ") + )); + assert!(crate::util::auth::api_scope_uses_basic_auth( + Some(" "), + Some("admin") + )); + } } diff --git a/src/main.rs b/src/main.rs index 599ac320..ee02a01a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,8 +122,10 @@ async fn main() -> std::io::Result<()> { web::scope("/api/v1") .app_data(web::JsonConfig::default().error_handler(api::json_error_handler)) .wrap(Condition::new( - ARGS.auth_basic_username.is_some() - && ARGS.auth_basic_username.as_ref().unwrap().trim() != "", + util::auth::api_scope_uses_basic_auth( + ARGS.api_key.as_deref().map(String::as_str), + ARGS.auth_basic_username.as_deref().map(String::as_str), + ), HttpAuthentication::basic(util::auth::api_auth_validator), )) .route("/paste", web::post().to(api::create_paste)) diff --git a/src/util/auth.rs b/src/util/auth.rs index a527aefa..04a6fac6 100644 --- a/src/util/auth.rs +++ b/src/util/auth.rs @@ -9,6 +9,13 @@ use futures::TryStreamExt; use crate::args::ARGS; +/// Basic Auth guards `/api/v1` only when no API key is configured: both use the +/// `Authorization` header, so a configured key is the sole API credential. +pub fn api_scope_uses_basic_auth(api_key: Option<&str>, basic_username: Option<&str>) -> bool { + let is_set = |v: Option<&str>| v.map(|v| !v.trim().is_empty()).unwrap_or(false); + is_set(basic_username) && !is_set(api_key) +} + pub async fn auth_validator( req: ServiceRequest, creds: BasicAuth,