Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`. When no API key is set but Basic Auth is configured, Basic Auth guards all `/api/v1/` routes **except `/health`** instead.

### Paste IDs

Expand Down
17 changes: 10 additions & 7 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`. 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 <key>`.
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).

Expand Down Expand Up @@ -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:
Expand Down
71 changes: 71 additions & 0 deletions src/endpoints/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
));
}
}
6 changes: 4 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions src/util/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading