Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions aw-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <api_key>
/// Leave unset (default) to disable authentication.
#[serde(default = "default_api_key")]
pub api_key: Option<String>,

#[serde(default = "default_cors")]
pub cors: Vec<String>,

Expand All @@ -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(),
Expand Down Expand Up @@ -83,6 +91,10 @@ fn default_address() -> String {
"127.0.0.1".to_string()
}

fn default_api_key() -> Option<String> {
None
}

fn default_cors() -> Vec<String> {
Vec::<String>::new()
}
Expand Down
238 changes: 238 additions & 0 deletions aw-server/src/endpoints/apikey.rs
Original file line number Diff line number Diff line change
@@ -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 <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
//! 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"];
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 {
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 <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;
}

// Always allow public paths
if PUBLIC_PATHS.contains(&request.uri().path().as_str()) {
return;
}

// Validate Authorization: Bearer <key>
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,
};
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.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);
}
}
3 changes: 3 additions & 0 deletions aw-server/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ mod util;
mod bucket;
mod cors;
mod export;
mod apikey;
mod hostcheck;
mod import;
mod query;
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