Skip to content

feat(server): add opt-in API key authentication via Bearer token#585

Merged
ErikBjare merged 4 commits intoActivityWatch:masterfrom
TimeToBuildBob:feat/api-key-auth
Apr 17, 2026
Merged

feat(server): add opt-in API key authentication via Bearer token#585
ErikBjare merged 4 commits intoActivityWatch:masterfrom
TimeToBuildBob:feat/api-key-auth

Conversation

@TimeToBuildBob
Copy link
Copy Markdown
Contributor

Summary

Adds opt-in API key authentication to aw-server-rust. When api_key is set in config.toml, all API endpoints except /api/0/info require an Authorization: Bearer <key> header.

Implementation: Rocket Fairing (same pattern as the existing HostCheck fairing) — applies globally without modifying individual endpoints.

Config (~/.config/activitywatch/aw-server-rust/config.toml):

api_key = "your-secret-key-here"

Leave unset (default) to disable authentication — existing setups are unaffected.

Behavior

  • GET /api/0/info — always public (health check / version endpoint)
  • OPTIONS preflight requests — always pass through (CORS)
  • 🔒 All other endpoints — require Authorization: Bearer <api_key>
  • 401 Unauthorized response with descriptive error on failure

Tests

5 new unit tests covering:

  • No api_key configured → all endpoints accessible
  • api_key set → /api/0/info still public
  • api_key set → other endpoints require valid key
  • Invalid key → 401
  • Wrong auth scheme (Basic) → 401

Notes

  • This fixes the local security issue on Android where any app can access the AW API
  • Useful for users exposing AW on non-localhost networks (with CORS configured)
  • Key is stored in config.toml — users manage it manually in MVP scope

Closes #494
Refs ActivityWatch/activitywatch#32, ActivityWatch/activitywatch#1199

When api_key is set in config.toml, all API endpoints except /api/0/info
require an Authorization: Bearer <key> 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#494
Refs ActivityWatch/activitywatch#32, ActivityWatch/activitywatch#1199
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 16, 2026

Greptile Summary

This PR adds opt-in API key authentication to aw-server-rust via a Rocket Fairing that mirrors the existing HostCheck pattern. When api_key is set in config.toml, all /api/* endpoints except /api/0/info require an Authorization: Bearer <key> header; non-API paths (web UI assets, /pages/ custom statics) and OPTIONS preflights are always passed through.

Previously-reported issues are all addressed in this revision: subtle::ConstantTimeEq is used for the token comparison, empty keys are normalised to None (disabling auth), and the /api/ prefix guard keeps the web UI accessible. Two minor P2 gaps remain: no test exercises the static-asset pass-through, and the Option::None default for api_key is silently omitted by toml::to_string, so users who rely on the auto-generated config.toml template won't see a commented example for this new option.

Confidence Score: 5/5

Safe to merge — all previously-reported P0/P1 issues are addressed; only P2 suggestions remain.

All critical prior findings (timing attack, empty-key bypass, web UI blocked) are resolved. The two remaining observations are both P2: a missing test for static-path pass-through and the config template discoverability gap. Neither blocks correctness or security of the feature.

aw-server/src/endpoints/apikey.rs (missing static-path test), aw-server/src/config.rs (api_key omitted from generated config template)

Important Files Changed

Filename Overview
aw-server/src/endpoints/apikey.rs New Rocket fairing implementing opt-in Bearer token auth; correctly uses subtle::ConstantTimeEq, normalises empty keys to None, and guards only /api/* paths — but lacks a test asserting non-/api/ assets remain accessible.
aw-server/src/config.rs Adds optional api_key field to AWConfig; correct serde defaults, but Option::None is omitted by toml::to_string so the auto-generated config template will contain no api_key hint for users.
aw-server/src/endpoints/mod.rs Wires ApiKeyCheck fairing into build_rocket in the correct position (after CORS, before route handling); no issues.
aw-server/Cargo.toml Adds subtle = "2" dependency for constant-time comparison; straightforward and appropriate.
Cargo.lock Lock file updated with subtle 2.6.1 checksum; expected change.

Sequence Diagram

sequenceDiagram
    participant Client
    participant ApiKeyCheck (Fairing)
    participant Router
    participant Handler

    Note over ApiKeyCheck (Fairing): on_request fires before routing

    Client->>ApiKeyCheck (Fairing): OPTIONS /api/0/buckets
    ApiKeyCheck (Fairing)-->>Router: pass through (CORS preflight)
    Router->>Handler: OPTIONS catch-all
    Handler-->>Client: 200 OK

    Client->>ApiKeyCheck (Fairing): GET / (static asset)
    ApiKeyCheck (Fairing)-->>Router: pass through (not /api/)
    Router->>Handler: root_index
    Handler-->>Client: 200 OK (web UI)

    Client->>ApiKeyCheck (Fairing): GET /api/0/info
    ApiKeyCheck (Fairing)-->>Router: pass through (PUBLIC_PATHS)
    Router->>Handler: server_info
    Handler-->>Client: 200 OK

    Client->>ApiKeyCheck (Fairing): GET /api/0/buckets (no token)
    ApiKeyCheck (Fairing)->>ApiKeyCheck (Fairing): redirect_unauthorized → /apikey_fairing
    ApiKeyCheck (Fairing)->>Router: GET /apikey_fairing
    Router->>Handler: FairingErrorRoute
    Handler-->>Client: 401 Unauthorized

    Client->>ApiKeyCheck (Fairing): GET /api/0/buckets (Bearer valid)
    ApiKeyCheck (Fairing)->>ApiKeyCheck (Fairing): ct_eq → match
    ApiKeyCheck (Fairing)-->>Router: pass through
    Router->>Handler: buckets_get
    Handler-->>Client: 200 OK
Loading

Reviews (3): Last reviewed commit: "fix(server): scope API key auth to /api/..." | Re-trigger Greptile

Comment on lines +113 to +122
let valid = match auth_header {
Some(value) => {
if let Some(token) = value.strip_prefix("Bearer ") {
token == api_key
} else {
false
}
}
None => false,
};
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,
};

Comment on lines +95 to +99
async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
let api_key = match &self.api_key {
None => return, // auth disabled
Some(k) => k,
};
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,
};

Comment on lines +56 to +62
) -> 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)
}
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\"").)

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 16, 2026

Codecov Report

❌ Patch coverage is 97.61905% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 68.39%. Comparing base (656f3c9) to head (572cedc).
⚠️ Report is 44 commits behind head on master.

Files with missing lines Patch % Lines
aw-server/src/endpoints/apikey.rs 97.43% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #585      +/-   ##
==========================================
- Coverage   70.81%   68.39%   -2.42%     
==========================================
  Files          51       55       +4     
  Lines        2916     3237     +321     
==========================================
+ Hits         2065     2214     +149     
- Misses        851     1023     +172     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

Addressed both security findings from the Greptile review (commit 7266413):

  • Timing oracle: Replaced token == api_key with token.as_bytes().ct_eq(api_key.as_bytes()) from the subtle crate (constant-time comparison). Added subtle = "2" to aw-server/Cargo.toml.
  • Empty key bypass: Added a guard in ApiKeyCheck::new() that converts an empty api_key string to None with a warn!() log message, so an empty-string config silently disables auth rather than silently accepting empty Bearer tokens.
  • Added a test for the empty-key case (test_empty_api_key_disables_auth).

Also fixed the cargo fmt failure: mod apikey was out of alphabetical order in endpoints/mod.rs.

All 6 apikey unit tests pass.

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment on lines +33 to +34
/// Paths that are always accessible without authentication.
const PUBLIC_PATHS: &[&str] = &["/api/0/info"];
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;
}

@ErikBjare
Copy link
Copy Markdown
Member

In ~/.config/activitywatch/aw-server-rust/config.toml the api_key should be under some suitable heading, we don't want section-less keys in the top-level.

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

@ErikBjare
Copy link
Copy Markdown
Member

@TimeToBuildBob Did you see #585 (comment), how is the config structure looking?

@ErikBjare
Copy link
Copy Markdown
Member

@TimeToBuildBob Also, I kinda thought there were already some kind of API key loading and generation in aw-server-rust?? Or is this just an old/stale PR?

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@ErikBjare Answering both questions:

1. Is this PR stale / does aw-server-rust already have API key code?
Not stale — it's a fresh introduction. I confirmed against origin/master: there's no api_key field in aw-server/src/config.rs, no apikey.rs/auth.rs in aw-server/src/endpoints/, and no Bearer-token handling anywhere in the crate. PR #585 is the first time API key auth lands in aw-server-rust.

You may be thinking of one of:

  • aw-server (Python) which had token logic at one point
  • The --testing flag, which gates some endpoints but isn't auth
  • Discussion/proposals from an older issue (happy to dig if you remember roughly when)

2. Config structure (per your 11:04 comment)
Currently the field is still top-level in AWConfig:

api_key = "your-secret-key-here"
cors = []

i.e. it ships flat alongside address, port, cors. That violates your "no section-less keys at top level" rule — you're right.

I'll push a follow-up that nests it under a heading. The natural shape, given the existing flat fields, would be [auth] (since address/port are already top-level for legacy reasons and we don't want to break those):

address = "127.0.0.1"
port = 5600

[auth]
api_key = "your-secret-key-here"

Or if you'd prefer, [server.auth] with everything migrated under [server] (bigger breaking change). Which do you want? I'll default to [auth] if I don't hear back, since it's additive and doesn't break existing configs.

@ErikBjare
Copy link
Copy Markdown
Member

[auth] makes sense to me

@ErikBjare
Copy link
Copy Markdown
Member

@TimeToBuildBob You seem to not be very responsive on these repos... I need to explicitly tag you.

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>
@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@ErikBjare Done — pushed refactor(config): nest api_key under [auth] section (572cedc).

Config now looks like:

address = "127.0.0.1"
port = 5600

[auth]
api_key = "your-secret-key-here"

All 6 unit tests still pass. Existing configs without an [auth] section continue to work (defaults to auth disabled). Ready to merge.

@ErikBjare ErikBjare merged commit 7c33810 into ActivityWatch:master Apr 17, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Password based authentication

2 participants