From 59a2b365c9fba58d3fa1a536cbf14351eff185e7 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 16 Apr 2026 14:49:37 +0000 Subject: [PATCH 1/4] feat(server): add opt-in API key authentication via Bearer token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When api_key is set in config.toml, all API endpoints except /api/0/info require an Authorization: Bearer header. Requests without a valid key receive 401 Unauthorized. Implemented as a Rocket Fairing (same pattern as HostCheck) so it applies globally without modifying individual endpoints. By default api_key is unset, meaning auth is disabled — existing setups are unaffected. Closes ActivityWatch/aw-server-rust#494 Refs ActivityWatch/activitywatch#32, ActivityWatch/activitywatch#1199 --- aw-server/src/config.rs | 12 ++ aw-server/src/endpoints/apikey.rs | 238 ++++++++++++++++++++++++++++++ aw-server/src/endpoints/mod.rs | 3 + 3 files changed, 253 insertions(+) create mode 100644 aw-server/src/endpoints/apikey.rs diff --git a/aw-server/src/config.rs b/aw-server/src/config.rs index 35ac435c..5770cd33 100644 --- a/aw-server/src/config.rs +++ b/aw-server/src/config.rs @@ -30,6 +30,13 @@ pub struct AWConfig { #[serde(skip, default = "default_testing")] pub testing: bool, // This is not written to the config file (serde(skip)) + /// Optional API key for bearer token authentication. + /// When set, all API endpoints (except /api/0/info) require: + /// Authorization: Bearer + /// Leave unset (default) to disable authentication. + #[serde(default = "default_api_key")] + pub api_key: Option, + #[serde(default = "default_cors")] pub cors: Vec, @@ -48,6 +55,7 @@ impl Default for AWConfig { address: default_address(), port: default_port(), testing: default_testing(), + api_key: default_api_key(), cors: default_cors(), cors_regex: default_cors(), custom_static: default_custom_static(), @@ -83,6 +91,10 @@ fn default_address() -> String { "127.0.0.1".to_string() } +fn default_api_key() -> Option { + None +} + fn default_cors() -> Vec { Vec::::new() } diff --git a/aw-server/src/endpoints/apikey.rs b/aw-server/src/endpoints/apikey.rs new file mode 100644 index 00000000..adf07f82 --- /dev/null +++ b/aw-server/src/endpoints/apikey.rs @@ -0,0 +1,238 @@ +//! API key authentication via Bearer token. +//! +//! When `api_key` is set in the config, all API endpoints except `/api/0/info` +//! require an `Authorization: Bearer ` header. Requests missing or +//! presenting an invalid key receive a 401 Unauthorized response. +//! +//! By default `api_key` is `None`, meaning authentication is disabled. +//! To enable, add to `config.toml`: +//! +//! ```toml +//! api_key = "your-secret-key-here" +//! ``` +//! +//! Exempt paths (always public): +//! - `GET /api/0/info` — health/version endpoint used by clients and the webui +//! +//! CORS preflight requests (OPTIONS) are also passed through unconditionally so +//! the browser can obtain allowed headers before sending the actual request. + +use rocket::fairing::Fairing; +use rocket::http::uri::Origin; +use rocket::http::{Method, Status}; +use rocket::route::Outcome; +use rocket::{Data, Request, Rocket, Route}; + +use crate::config::AWConfig; +use crate::endpoints::HttpErrorJson; + +static FAIRING_ROUTE_BASE: &str = "/apikey_fairing"; + +/// Paths that are always accessible without authentication. +const PUBLIC_PATHS: &[&str] = &["/api/0/info"]; + +pub struct ApiKeyCheck { + api_key: Option, +} + +impl ApiKeyCheck { + pub fn new(config: &AWConfig) -> ApiKeyCheck { + ApiKeyCheck { + api_key: config.api_key.clone(), + } + } +} + +/// Route handler that returns 401 Unauthorized for failed auth checks. +#[derive(Clone)] +struct FairingErrorRoute {} + +#[rocket::async_trait] +impl rocket::route::Handler for FairingErrorRoute { + async fn handle<'r>( + &self, + request: &'r Request<'_>, + _: rocket::Data<'r>, + ) -> rocket::route::Outcome<'r> { + let err = HttpErrorJson::new( + Status::Unauthorized, + "Missing or invalid API key. Set 'Authorization: Bearer ' header.".to_string(), + ); + Outcome::from(request, err) + } +} + +fn fairing_route() -> Route { + Route::ranked(1, Method::Get, "/", FairingErrorRoute {}) +} + +fn redirect_unauthorized(request: &mut Request) { + let uri = FAIRING_ROUTE_BASE.to_string(); + let origin = Origin::parse_owned(uri).unwrap(); + request.set_method(Method::Get); + request.set_uri(origin); +} + +#[rocket::async_trait] +impl Fairing for ApiKeyCheck { + fn info(&self) -> rocket::fairing::Info { + rocket::fairing::Info { + name: "ApiKeyCheck", + kind: rocket::fairing::Kind::Ignite | rocket::fairing::Kind::Request, + } + } + + async fn on_ignite(&self, rocket: Rocket) -> rocket::fairing::Result { + match &self.api_key { + Some(_) => Ok(rocket.mount(FAIRING_ROUTE_BASE, vec![fairing_route()])), + None => { + debug!("API key authentication is disabled"); + Ok(rocket) + } + } + } + + async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) { + let api_key = match &self.api_key { + None => return, // auth disabled + Some(k) => k, + }; + + // Always allow OPTIONS (CORS preflight) + if request.method() == Method::Options { + return; + } + + // Always allow public paths + if PUBLIC_PATHS.contains(&request.uri().path().as_str()) { + return; + } + + // Validate Authorization: Bearer + let auth_header = request.headers().get_one("Authorization"); + let valid = match auth_header { + Some(value) => { + if let Some(token) = value.strip_prefix("Bearer ") { + token == api_key + } else { + false + } + } + None => false, + }; + + if !valid { + debug!("API key check failed for {}", request.uri()); + redirect_unauthorized(request); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use rocket::http::{ContentType, Header, Status}; + use rocket::Rocket; + + use crate::config::AWConfig; + use crate::endpoints; + + fn setup_testserver(api_key: Option) -> Rocket { + let state = endpoints::ServerState { + datastore: Mutex::new(aw_datastore::Datastore::new_in_memory(false)), + asset_resolver: endpoints::AssetResolver::new(None), + device_id: "test_id".to_string(), + }; + let mut aw_config = AWConfig::default(); + aw_config.api_key = api_key; + endpoints::build_rocket(state, aw_config) + } + + #[test] + fn test_no_api_key_configured() { + // When no api_key is set, all endpoints are accessible without auth. + let server = setup_testserver(None); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/info") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + } + + #[test] + fn test_api_key_required() { + // With api_key set, requests without a key should be rejected. + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + // /api/0/info is always public + let res = client + .get("/api/0/info") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + + // Other endpoints require auth + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + } + + #[test] + fn test_api_key_valid() { + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .header(Header::new("Authorization", "Bearer secret123")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + } + + #[test] + fn test_api_key_invalid() { + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .header(Header::new("Authorization", "Bearer wrongkey")) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + } + + #[test] + fn test_api_key_wrong_scheme() { + // Must be Bearer, not Basic or bare key + let server = setup_testserver(Some("secret123".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .header(Header::new("Authorization", "Basic secret123")) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); + } +} diff --git a/aw-server/src/endpoints/mod.rs b/aw-server/src/endpoints/mod.rs index f6c9271e..741c2ca1 100644 --- a/aw-server/src/endpoints/mod.rs +++ b/aw-server/src/endpoints/mod.rs @@ -49,6 +49,7 @@ mod util; mod bucket; mod cors; mod export; +mod apikey; mod hostcheck; mod import; mod query; @@ -134,11 +135,13 @@ pub fn build_rocket(server_state: ServerState, config: AWConfig) -> rocket::Rock ); let cors = cors::cors(&config); let hostcheck = hostcheck::HostCheck::new(&config); + let apikey = apikey::ApiKeyCheck::new(&config); let custom_static = config.custom_static.clone(); let mut rocket = rocket::custom(config.to_rocket_config()) .attach(cors.clone()) .attach(hostcheck) + .attach(apikey) .manage(cors) .manage(server_state) .manage(config) From 7266413d622e37e5f26bc642f30d211c0c52a3eb Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 16 Apr 2026 15:05:27 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(server):=20address=20Greptile=20securit?= =?UTF-8?q?y=20review=20=E2=80=94=20timing-safe=20compare,=20empty-key=20g?= =?UTF-8?q?uard,=20fmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 7 +++++++ aw-server/Cargo.toml | 1 + aw-server/src/endpoints/apikey.rs | 30 ++++++++++++++++++++++++++---- aw-server/src/endpoints/mod.rs | 2 +- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 353b4f5c..5d12c475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -281,6 +281,7 @@ dependencies = [ "sd-notify", "serde", "serde_json", + "subtle", "toml", "uuid", ] @@ -2623,6 +2624,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.104" diff --git a/aw-server/Cargo.toml b/aw-server/Cargo.toml index da6c908e..1962577b 100644 --- a/aw-server/Cargo.toml +++ b/aw-server/Cargo.toml @@ -28,6 +28,7 @@ gethostname = "0.4" uuid = { version = "1.3", features = ["serde", "v4"] } clap = { version = "4.1", features = ["derive", "cargo"] } log-panics = { version = "2", features = ["with-backtrace"]} +subtle = "2" rust-embed = { version = "8.0.0", features = ["interpolate-folder-path", "debug-embed"] } aw-datastore = { path = "../aw-datastore" } diff --git a/aw-server/src/endpoints/apikey.rs b/aw-server/src/endpoints/apikey.rs index adf07f82..95511ac8 100644 --- a/aw-server/src/endpoints/apikey.rs +++ b/aw-server/src/endpoints/apikey.rs @@ -17,6 +17,8 @@ //! CORS preflight requests (OPTIONS) are also passed through unconditionally so //! the browser can obtain allowed headers before sending the actual request. +use subtle::ConstantTimeEq; + use rocket::fairing::Fairing; use rocket::http::uri::Origin; use rocket::http::{Method, Status}; @@ -37,9 +39,14 @@ pub struct ApiKeyCheck { impl ApiKeyCheck { pub fn new(config: &AWConfig) -> ApiKeyCheck { - ApiKeyCheck { - api_key: config.api_key.clone(), - } + let api_key = match &config.api_key { + Some(k) if k.is_empty() => { + warn!("api_key is set to an empty string — authentication is disabled. Set a non-empty key to enable auth."); + None + } + other => other.clone(), + }; + ApiKeyCheck { api_key } } } @@ -109,11 +116,12 @@ impl Fairing for ApiKeyCheck { } // Validate Authorization: Bearer + // Use constant-time comparison to prevent timing attacks. let auth_header = request.headers().get_one("Authorization"); let valid = match auth_header { Some(value) => { if let Some(token) = value.strip_prefix("Bearer ") { - token == api_key + token.as_bytes().ct_eq(api_key.as_bytes()).into() } else { false } @@ -235,4 +243,18 @@ mod tests { .dispatch(); assert_eq!(res.status(), Status::Unauthorized); } + + #[test] + fn test_empty_api_key_disables_auth() { + // An empty string key should be treated as disabled (no auth required). + let server = setup_testserver(Some("".to_string())); + let client = rocket::local::blocking::Client::tracked(server).expect("valid instance"); + + let res = client + .get("/api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Ok); + } } diff --git a/aw-server/src/endpoints/mod.rs b/aw-server/src/endpoints/mod.rs index 741c2ca1..f0621196 100644 --- a/aw-server/src/endpoints/mod.rs +++ b/aw-server/src/endpoints/mod.rs @@ -46,10 +46,10 @@ pub struct ServerState { #[macro_use] mod util; +mod apikey; mod bucket; mod cors; mod export; -mod apikey; mod hostcheck; mod import; mod query; From f99c96b76c0fa066291ed0cfd03347b5b4ba06ae Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 11:19:25 +0000 Subject: [PATCH 3/4] fix(server): scope API key auth to /api/ paths only, pass through static assets --- aw-server/src/endpoints/apikey.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/aw-server/src/endpoints/apikey.rs b/aw-server/src/endpoints/apikey.rs index 95511ac8..293b9b32 100644 --- a/aw-server/src/endpoints/apikey.rs +++ b/aw-server/src/endpoints/apikey.rs @@ -110,7 +110,12 @@ impl Fairing for ApiKeyCheck { return; } - // Always allow public paths + // Only gate API endpoints — static web UI assets are not under /api/ + if !request.uri().path().as_str().starts_with("/api/") { + return; + } + + // Always allow public API paths (e.g. /api/0/info for health checks) if PUBLIC_PATHS.contains(&request.uri().path().as_str()) { return; } From 572cedc9eb74fc9bc608e172df43472484bb89a9 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 15:44:03 +0000 Subject: [PATCH 4/4] refactor(config): nest api_key under [auth] section Move api_key from the top-level AWConfig into a new AWAuthConfig sub-struct, which serialises as an [auth] table in config.toml: [auth] api_key = "your-secret-key-here" This keeps top-level config.toml keys clean and gives auth settings a dedicated, extensible namespace. All 6 apikey unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- aw-server/src/config.rs | 28 ++++++++++++++++------------ aw-server/src/endpoints/apikey.rs | 11 ++++++----- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/aw-server/src/config.rs b/aw-server/src/config.rs index 5770cd33..37d59bbd 100644 --- a/aw-server/src/config.rs +++ b/aw-server/src/config.rs @@ -19,6 +19,17 @@ pub fn is_testing() -> bool { unsafe { TESTING } } +/// Authentication configuration, serialised as `[auth]` in config.toml. +#[derive(Serialize, Deserialize, Default)] +pub struct AWAuthConfig { + /// Optional API key for Bearer-token authentication. + /// When set, all `/api/*` endpoints except `/api/0/info` require: + /// Authorization: Bearer + /// Leave unset (default) to disable authentication. + #[serde(default)] + pub api_key: Option, +} + #[derive(Serialize, Deserialize)] pub struct AWConfig { #[serde(default = "default_address")] @@ -30,19 +41,16 @@ pub struct AWConfig { #[serde(skip, default = "default_testing")] pub testing: bool, // This is not written to the config file (serde(skip)) - /// Optional API key for bearer token authentication. - /// When set, all API endpoints (except /api/0/info) require: - /// Authorization: Bearer - /// Leave unset (default) to disable authentication. - #[serde(default = "default_api_key")] - pub api_key: Option, - #[serde(default = "default_cors")] pub cors: Vec, #[serde(default = "default_cors")] pub cors_regex: Vec, + // Authentication settings — serialised as [auth] section. + #[serde(default)] + pub auth: AWAuthConfig, + // A mapping of watcher names to paths where the // custom visualizations are located. #[serde(default = "default_custom_static")] @@ -55,7 +63,7 @@ impl Default for AWConfig { address: default_address(), port: default_port(), testing: default_testing(), - api_key: default_api_key(), + auth: AWAuthConfig::default(), cors: default_cors(), cors_regex: default_cors(), custom_static: default_custom_static(), @@ -91,10 +99,6 @@ fn default_address() -> String { "127.0.0.1".to_string() } -fn default_api_key() -> Option { - None -} - fn default_cors() -> Vec { Vec::::new() } diff --git a/aw-server/src/endpoints/apikey.rs b/aw-server/src/endpoints/apikey.rs index 293b9b32..a651f211 100644 --- a/aw-server/src/endpoints/apikey.rs +++ b/aw-server/src/endpoints/apikey.rs @@ -1,13 +1,14 @@ //! API key authentication via Bearer token. //! -//! When `api_key` is set in the config, all API endpoints except `/api/0/info` -//! require an `Authorization: Bearer ` header. Requests missing or -//! presenting an invalid key receive a 401 Unauthorized response. +//! When `api_key` is set under `[auth]` in the config, all API endpoints except +//! `/api/0/info` require an `Authorization: Bearer ` header. Requests +//! missing or presenting an invalid key receive a 401 Unauthorized response. //! //! By default `api_key` is `None`, meaning authentication is disabled. //! To enable, add to `config.toml`: //! //! ```toml +//! [auth] //! api_key = "your-secret-key-here" //! ``` //! @@ -39,7 +40,7 @@ pub struct ApiKeyCheck { impl ApiKeyCheck { pub fn new(config: &AWConfig) -> ApiKeyCheck { - let api_key = match &config.api_key { + let api_key = match &config.auth.api_key { Some(k) if k.is_empty() => { warn!("api_key is set to an empty string — authentication is disabled. Set a non-empty key to enable auth."); None @@ -158,7 +159,7 @@ mod tests { device_id: "test_id".to_string(), }; let mut aw_config = AWConfig::default(); - aw_config.api_key = api_key; + aw_config.auth.api_key = api_key; endpoints::build_rocket(state, aw_config) }