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
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions aw-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
16 changes: 16 additions & 0 deletions aw-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <api_key>
/// Leave unset (default) to disable authentication.
#[serde(default)]
pub api_key: Option<String>,
}

#[derive(Serialize, Deserialize)]
pub struct AWConfig {
#[serde(default = "default_address")]
Expand All @@ -36,6 +47,10 @@ pub struct AWConfig {
#[serde(default = "default_cors")]
pub cors_regex: Vec<String>,

// 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")]
Expand All @@ -48,6 +63,7 @@ impl Default for AWConfig {
address: default_address(),
port: default_port(),
testing: default_testing(),
auth: AWAuthConfig::default(),
cors: default_cors(),
cors_regex: default_cors(),
custom_static: default_custom_static(),
Expand Down
266 changes: 266 additions & 0 deletions aw-server/src/endpoints/apikey.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
//! API key authentication via Bearer token.
//!
//! When `api_key` is set under `[auth]` in the config, all API endpoints except
//! `/api/0/info` require an `Authorization: Bearer <key>` 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"
//! ```
//!
//! 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 subtle::ConstantTimeEq;

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"];
Comment on lines +34 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Web UI routes blocked when API key is set

PUBLIC_PATHS only exempts /api/0/info, so all static web UI routes (/, /css/, /js/, /fonts/, etc.) are gated behind Bearer auth. A browser cannot send Authorization: Bearer headers for ordinary page navigation, so the ActivityWatch web UI becomes completely inaccessible as soon as a user sets api_key. This breaks the primary user interface for anyone enabling this feature on a desktop install.

Restricting the check to the /api/ subtree — keeping static asset routes public — would match the stated intent of "protecting the API":

Suggested change
/// Paths that are always accessible without authentication.
const PUBLIC_PATHS: &[&str] = &["/api/0/info"];
/// Paths that are always accessible without authentication.
const PUBLIC_PATHS: &[&str] = &["/api/0/info"];
/// Only enforce auth for paths under this prefix.
const PROTECTED_PREFIX: &str = "/api/";

Then in on_request, replace the PUBLIC_PATHS check with:

// Only enforce auth on API routes; static web-UI assets are always public.
if !request.uri().path().starts_with(PROTECTED_PREFIX) {
    return;
}

// Within the API, /api/0/info is always public.
if PUBLIC_PATHS.contains(&request.uri().path().as_str()) {
    return;
}


pub struct ApiKeyCheck {
api_key: Option<String>,
}

impl ApiKeyCheck {
pub fn new(config: &AWConfig) -> ApiKeyCheck {
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
}
other => other.clone(),
};
ApiKeyCheck { api_key }
}
}

/// 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 <key>' header.".to_string(),
);
Outcome::from(request, err)
}
Comment on lines +64 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing WWW-Authenticate response header

RFC 7235 §4.1 requires that a 401 response include at least one WWW-Authenticate challenge so HTTP clients can discover the supported scheme automatically. Without it, generic HTTP clients have no machine-readable signal to prompt for credentials.

(This requires a small refactor of FairingErrorRoute::handle to build a Response manually and append .raw_header("WWW-Authenticate", "Bearer realm=\"aw-server\"").)

}

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::Build>) -> 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,
};
Comment on lines +103 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Empty API key trivially bypassed

If a user sets api_key = "" in config, the guard accepts any request that sends Authorization: Bearer (Bearer followed by an empty token), since "Bearer ".strip_prefix("Bearer ") yields Some("") and "" == "" is true. Filtering this out at the point the key is read keeps the authentication logic clean.

Suggested change
async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
let api_key = match &self.api_key {
None => return, // auth disabled
Some(k) => k,
};
let api_key = match &self.api_key {
None => return, // auth disabled
Some(k) if k.is_empty() => {
warn!("api_key is configured but empty — authentication is effectively disabled");
return;
}
Some(k) => k,
};


// Always allow OPTIONS (CORS preflight)
if request.method() == Method::Options {
return;
}

// 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;
}

// Validate Authorization: Bearer <key>
// 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.as_bytes().ct_eq(api_key.as_bytes()).into()
} else {
false
}
}
None => false,
};
Comment on lines +127 to +136
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Timing-safe comparison missing

The token == api_key comparison on line 116 short-circuits on the first mismatched byte, leaking timing information that a network-adjacent attacker can use as an oracle to guess the key character by character. This is especially relevant for the stated use case of non-localhost network exposure. Consider using the subtle crate's ConstantTimeEq or comparing HMAC digests of both values.

Suggested change
let valid = match auth_header {
Some(value) => {
if let Some(token) = value.strip_prefix("Bearer ") {
token == api_key
} else {
false
}
}
None => false,
};
let valid = match auth_header {
Some(value) => {
if let Some(token) = value.strip_prefix("Bearer ") {
// Use constant-time comparison to prevent timing oracle attacks
token.len() == api_key.len()
&& token
.bytes()
.zip(api_key.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
} 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<String>) -> Rocket<rocket::Build> {
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.auth.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);
}

#[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);
}
}
3 changes: 3 additions & 0 deletions aw-server/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub struct ServerState {

#[macro_use]
mod util;
mod apikey;
mod bucket;
mod cors;
mod export;
Expand Down Expand Up @@ -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)
Expand Down
Loading