Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Supported environment overrides:
- `SCIOT_CLIENT_DEVICE_ID`: static-client device ID.
- `SCIOT_CLIENT_MAX_CYCLES`: bounded client cycle count.
- `SCIOT_CLIENT_RUN_DURATION_SECONDS`: bounded client run duration.
- `SCIOT_API_KEY`: server API key for `communication.http.auth`, overrides `auth.api_key` when set.

## Validated fields

Expand All @@ -40,6 +41,7 @@ Server validation covers:
- local-inference probability in the inclusive range `0.0..1.0`;
- delay sections and delay distributions;
- boolean flags such as `verbose`, `debug_cprofiler`, compression, and input saving;
- `communication.http.auth.enabled` (boolean) and `communication.http.auth.api_key` (string), gating the registration/device-data routes behind an `X-API-Key` header;
- `evaluation.split.max_rows` and `evaluation.split.interval_minutes` (non-negative);
- `evaluation.outputs.inference_cycles` and `evaluation.outputs.offloading_decisions` (booleans);
- optional Firestore telemetry publishing under `evaluation.firestore`.
Expand Down
6 changes: 6 additions & 0 deletions docs/config/server.full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ communication:
timeout_keep_alive: 30
backlog: 2048
save_device_input: true
auth:
# When true, the registration/device_input/device_inference_result
# routes require an X-API-Key header matching api_key (or the
# SCIOT_API_KEY env var, which takes precedence over api_key).
enabled: false
api_key: null
compression:
enabled: false
endpoints:
Expand Down
14 changes: 14 additions & 0 deletions src/sciot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,20 @@ def _validate_http_server_transport(
errors,
path="communication.http.compression.enabled",
)
auth = config.get("auth")
if auth is not None:
if not isinstance(auth, dict):
errors.append("communication.http.auth: must be a mapping")
else:
_optional_bool(
auth,
"enabled",
errors,
path="communication.http.auth.enabled",
)
api_key = auth.get("api_key")
if api_key is not None and not isinstance(api_key, str):
errors.append("communication.http.auth.api_key: must be a string")
_optional_positive_int(
config,
"timeout_keep_alive",
Expand Down
39 changes: 39 additions & 0 deletions src/server/communication/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from server.logger.log import logger
import asyncio
import ntplib
import os
import threading
import time
from pathlib import Path
Expand All @@ -16,6 +17,9 @@
if TYPE_CHECKING:
from server.communication.request_handler import RequestHandler

# Header devices must send their API key in when auth is enabled.
API_KEY_HEADER = "X-API-Key"


class HttpServer:
def __init__(
Expand Down Expand Up @@ -49,6 +53,28 @@ def __init__(
self.port = port
self.endpoints = endpoints

# =====================================================
# API-key authentication for the registration/device-data routes.
# Enabled via settings.yaml -> communication.http.auth.enabled.
# The key can be set in settings.yaml (auth.api_key) or via the
# SCIOT_API_KEY env var, which takes precedence.
# =====================================================
auth_cfg = http_cfg.get("auth", {}) or {}
self.auth_enabled = bool(auth_cfg.get("enabled", False))
self.api_key = os.environ.get("SCIOT_API_KEY") or auth_cfg.get("api_key")
if self.auth_enabled and not self.api_key:
raise ValueError(
"communication.http.auth.enabled is true but no API key is "
"configured (set communication.http.auth.api_key or the "
"SCIOT_API_KEY environment variable)"
)
self._protected_paths = {
endpoints.get("registration"),
endpoints.get("device_input"),
endpoints.get("device_inference_result"),
}
self.app.middleware("http")(self._enforce_api_key)

self.devices = set()

# Load verbose setting
Expand Down Expand Up @@ -461,6 +487,19 @@ async def split_inference(request: Request):
},
)

async def _enforce_api_key(self, request: Request, call_next):
"""Reject unauthenticated requests to the registration/device-data routes."""
if self.auth_enabled and request.url.path in self._protected_paths:
if request.headers.get(API_KEY_HEADER) != self.api_key:
return JSONResponse(
status_code=401,
content={
"code": "invalid_api_key",
"message": "Missing or invalid API key",
},
)
return await call_next(request)

def _load_verbose_config(self):
"""Load verbose configuration from cached settings."""
return self._settings().get("verbose", False)
Expand Down
6 changes: 6 additions & 0 deletions src/server/settings.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
communication:
http:
auth:
# When true, the registration/device_input/device_inference_result
# routes require an X-API-Key header matching api_key (or the
# SCIOT_API_KEY env var, which takes precedence over api_key).
enabled: false
api_key: null
compression:
enabled: false
endpoints:
Expand Down
121 changes: 121 additions & 0 deletions tests/integration/test_http_api_key_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Coverage for the optional API-key authentication on the HTTP server."""

import pytest
from fastapi.testclient import TestClient

from server.communication.http_server import HttpServer


def _make_server(monkeypatch, *, enabled, api_key=None, env_key=None):
monkeypatch.setattr(HttpServer, "_sync_with_ntp", lambda self: 0.0)
if env_key is None:
monkeypatch.delenv("SCIOT_API_KEY", raising=False)
else:
monkeypatch.setenv("SCIOT_API_KEY", env_key)

class DummyRequestHandler:
device_profiles = {}

def handle_registration(self, device_id, model_hash=""):
return device_id

def handle_device_input(self, body, height, width):
return None

@staticmethod
def handle_offloading_layer(best_offloading_layer):
return best_offloading_layer

endpoints = {
"registration": "/api/registration",
"device_input": "/api/device_input",
"offloading_layer": "/api/offloading_layer",
"device_inference_result": "/api/device_inference_result",
}
return HttpServer(
host="127.0.0.1",
port=0,
endpoints=endpoints,
ntp_server="unused.invalid",
input_height=1,
input_width=1,
last_offloading_layer=1,
request_handler=DummyRequestHandler(),
settings={
"verbose": False,
"communication": {
"http": {
"auth": {"enabled": enabled, "api_key": api_key},
}
},
},
)


def test_auth_disabled_by_default_allows_requests_without_key(monkeypatch):
server = _make_server(monkeypatch, enabled=False)
with TestClient(server.app) as client:
response = client.post("/api/registration", json={"device_id": "dev-1"})
assert response.status_code == 200


def test_auth_enabled_rejects_missing_key(monkeypatch):
server = _make_server(monkeypatch, enabled=True, api_key="secret")
with TestClient(server.app) as client:
response = client.post("/api/registration", json={"device_id": "dev-1"})
assert response.status_code == 401


def test_auth_enabled_rejects_wrong_key(monkeypatch):
server = _make_server(monkeypatch, enabled=True, api_key="secret")
with TestClient(server.app) as client:
response = client.post(
"/api/registration",
json={"device_id": "dev-1"},
headers={"X-API-Key": "wrong"},
)
assert response.status_code == 401


def test_auth_enabled_accepts_correct_key(monkeypatch):
server = _make_server(monkeypatch, enabled=True, api_key="secret")
with TestClient(server.app) as client:
response = client.post(
"/api/registration",
json={"device_id": "dev-1"},
headers={"X-API-Key": "secret"},
)
assert response.status_code == 200


def test_env_var_api_key_takes_precedence_over_settings(monkeypatch):
server = _make_server(
monkeypatch, enabled=True, api_key="settings-key", env_key="env-key"
)
with TestClient(server.app) as client:
rejected = client.post(
"/api/registration",
json={"device_id": "dev-1"},
headers={"X-API-Key": "settings-key"},
)
accepted = client.post(
"/api/registration",
json={"device_id": "dev-1"},
headers={"X-API-Key": "env-key"},
)
assert rejected.status_code == 401
assert accepted.status_code == 200


def test_unprotected_routes_are_not_gated_by_api_key(monkeypatch):
server = _make_server(monkeypatch, enabled=True, api_key="secret")
with TestClient(server.app) as client:
response = client.get("/api/offloading_layer")
assert response.status_code == 200


def test_enabling_auth_without_a_key_fails_fast(monkeypatch):
monkeypatch.setattr(HttpServer, "_sync_with_ntp", lambda self: 0.0)
monkeypatch.delenv("SCIOT_API_KEY", raising=False)
with pytest.raises(ValueError):
_make_server(monkeypatch, enabled=True, api_key=None)
16 changes: 16 additions & 0 deletions tests/unit/test_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,22 @@ def test_documented_example_configs_are_valid(path, loader):
),
"offloading_algo: unsupported keys",
),
(
lambda cfg: cfg["communication"]["http"].update(auth="not-a-mapping"),
"communication.http.auth: must be a mapping",
),
(
lambda cfg: cfg["communication"]["http"].update(
auth={"enabled": "yes"}
),
"communication.http.auth.enabled: must be true or false",
),
(
lambda cfg: cfg["communication"]["http"].update(
auth={"api_key": 12345}
),
"communication.http.auth.api_key: must be a string",
),
],
)
def test_invalid_server_config_reports_field_errors(mutate, expected_error):
Expand Down
Loading