Sidecar: auth token for the website-facing API

config.rs loads all runtime settings from an external sidecar.toml (path via
$UOLINK_CONFIG), with env-var overrides (UOLINK_WEB_TOKEN, UOLINK_WEB_BIND,
UOLINK_SHARD_BIND, UOLINK_DB_PATH). Nothing is compiled into the binary. On first
run the file is generated with a random 24-byte auth token, so the sidecar is
secured out of the box and the operator just copies the token to the website.

An axum middleware rejects any request to a non-/health route that does not
present the token, as Authorization: Bearer, X-Api-Key, or ?token= (the last so
browser WebSocket clients, which cannot set handshake headers, can authenticate).
The comparison is constant-time. An empty token disables auth and is only
tolerated on a loopback bind; binding to 0.0.0.0 with no token logs a warning.

Verified: /health open (200); /history 401 without a token, 401 with a wrong one,
200 with the right one via either Bearer or X-Api-Key; an authed shard query
falls through to 503 when no shard is connected; WS rejected (401) with a bad
?token= and upgraded (101) with the right one.

sidecar.toml is gitignored (holds the secret); sidecar.toml.example is committed
as the reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:56:55 -05:00
parent 946ba7027b
commit c4de5fa8ad
9 changed files with 469 additions and 13 deletions

View File

@@ -6,11 +6,14 @@
use std::time::Duration;
use std::sync::Arc;
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
extract::{Path, Query, State},
extract::{Path, Query, Request, State},
http::StatusCode,
response::IntoResponse,
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
@@ -30,11 +33,13 @@ pub struct AppState {
pub shard: ShardHandle,
pub rpc: Rpc,
pub store: Store,
/// Shared secret the website must present. Empty disables auth (loopback dev only).
pub token: Arc<String>,
}
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
let app = Router::new()
.route("/health", get(health))
// Everything except /health is behind the auth check.
let protected = Router::new()
.route("/ws", get(ws_upgrade))
// Queries (shard reply correlated by reqId).
.route("/char/:account/:slot", get(char_by_slot))
@@ -49,6 +54,11 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// History, read from SQLite rather than the shard.
.route("/history", get(history))
.route("/economy", get(economy))
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
let app = Router::new()
.route("/health", get(health))
.merge(protected)
.with_state(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
@@ -61,6 +71,62 @@ async fn health() -> impl IntoResponse {
"ok"
}
// ---- auth ----
/// Rejects any request that does not present the configured token. Skipped entirely when no token
/// is configured (loopback dev). The token may arrive as `Authorization: Bearer <t>`, `X-Api-Key:
/// <t>`, or `?token=<t>` (the last so browser WebSocket clients, which can't set headers, still
/// authenticate).
async fn auth(State(st): State<AppState>, req: Request, next: Next) -> Response {
if st.token.is_empty() {
return next.run(req).await; // auth disabled
}
let provided = extract_token(&req);
match provided {
Some(t) if constant_time_eq(t.as_bytes(), st.token.as_bytes()) => next.run(req).await,
_ => (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "missing or invalid auth token"})),
)
.into_response(),
}
}
fn extract_token(req: &Request) -> Option<String> {
// Authorization: Bearer <token>
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) {
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) {
return Some(rest.trim().to_string());
}
}
// X-Api-Key: <token>
if let Some(v) = req.headers().get("x-api-key").and_then(|h| h.to_str().ok()) {
return Some(v.trim().to_string());
}
// ?token=<token>
if let Some(q) = req.uri().query() {
for pair in q.split('&') {
if let Some(v) = pair.strip_prefix("token=") {
return Some(v.to_string());
}
}
}
None
}
/// Length-independent, early-return-free comparison, so a wrong token leaks no timing signal.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
// ---- shared reply handling ----
/// Turns an RPC result into an HTTP response. A `bridge.error` reply from the shard becomes a 4xx;