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
5 changes: 4 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ cd shelly-manager
# The api and cli services require this. Put it in a .env file to keep it.
export SHELLY_SECRET_KEY=$(openssl rand -base64 32 | tr '+/' '-_')

# Optional: require a token to use the API and Web UI. Unset by default (no login).
export SHELLY_AUTH_TOKEN="a-strong-random-token"

# Start development environment
docker compose up -d

Expand Down Expand Up @@ -104,7 +107,7 @@ uv sync --package shelly-manager-cli

### Running Backend Services

Commands that read or write stored credentials or backups need `SHELLY_SECRET_KEY` in the environment, the same value the compose stack uses. Everything else, including `--help` and `scan`, runs without it.
Commands that read or write stored credentials or backups need `SHELLY_SECRET_KEY` in the environment, the same value the compose stack uses. Everything else, including `--help` and `scan`, runs without it. The CLI talks to `core` in-process, never over HTTP, so the optional `SHELLY_AUTH_TOKEN` (API + Web UI login, see the root README) has no effect on it.

```bash
# CLI tool
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,16 +400,19 @@ openssl rand -base64 32 | tr '+/' '-_'
### 2. Set the Environment Variable

**Linux / macOS**

```bash
export SHELLY_SECRET_KEY="your-generated-key"
```

**Docker**

```bash
docker run -e SHELLY_SECRET_KEY="your-generated-key" ...
```

**docker-compose.yml**

```yaml
environment:
- SHELLY_SECRET_KEY=your-generated-key
Expand All @@ -435,6 +438,16 @@ shelly-manager credentials delete AABBCCDDEEFF

The same `SHELLY_SECRET_KEY` also encrypts device configuration **backup snapshots** at rest. Backups are stored in the local database (`{data_dir}/data.db`); if the key is rotated, existing encrypted snapshots can no longer be decrypted.

## Optional Authentication

By default, Shelly Manager has no login of its own — anyone who can reach the API or Web UI has full access. Set `SHELLY_AUTH_TOKEN` to require a shared auth token for both.

```bash
export SHELLY_AUTH_TOKEN="a-strong-random-token"
```

When set, every API request (except `/api/health` and `/api/auth/config`) must include `Authorization: Bearer <token>`, and the Web UI shows a login page asking for the token before it will load. Leave it unset to keep the zero-configuration default. Unlike `SHELLY_SECRET_KEY`, this is optional and unrelated to credential encryption.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

/docs and /docs/openapi.json also stay public, can we list them here? Worth also recommending a long random token, since there's no rate limiting on /auth/verify.


## Development

For local development and contributing to Shelly Manager:
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ services:
PORT: "8000"
DEBUG: "true"
SHELLY_SECRET_KEY: "${SHELLY_SECRET_KEY:?SHELLY_SECRET_KEY must be set - generate with: openssl rand -base64 32 | tr '+/' '-_'}"
# Optional: require a bearer token to use the API and Web UI. Unset by
# default (no login).
SHELLY_AUTH_TOKEN: "${SHELLY_AUTH_TOKEN:-}"
# Scheduled backups run in-process on the API and assume a single worker.
# Defaults shown; set SHELLY_BACKUP_SCHEDULER_ENABLED=false to disable.
SHELLY_BACKUP_SCHEDULER_ENABLED: "${SHELLY_BACKUP_SCHEDULER_ENABLED:-true}"
Expand Down
14 changes: 14 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ uv run --package shelly-manager-api python -m api.main
GET /api/health # Service health check
```

### Auth

See [Configuration](#configuration) for `SHELLY_AUTH_TOKEN`.

```bash
GET /api/auth/config # {"enabled": bool} - always public
GET /api/auth/verify # 200 with a valid token, 401 otherwise - gated like every other route below when a token is set
```

### Device Discovery

```bash
Expand Down Expand Up @@ -444,6 +453,7 @@ curl -X POST "http://localhost:8000/api/backups/1/restore" \
| `PORT` | `8000` | API server port |
| `DEBUG` | `false` | Enable debug mode |
| `SHELLY_SECRET_KEY` | (required) | Fernet key for credential encryption. Generate with: `openssl rand -base64 32 \| tr '+/' '-_'` |
| `SHELLY_AUTH_TOKEN` | (none, auth disabled) | Optional shared auth token. When set, every route except `/api/health` and `/api/auth/config` requires `Authorization: Bearer <token>` |
| `SHELLY_BACKUP_SCHEDULER_ENABLED` | `true` | Run the in-process scheduled-backup poller |
| `SHELLY_BACKUP_POLL_INTERVAL_SECONDS` | `60` | How often the scheduler checks for due backups |
| `SHELLY_FIRMWARE_ADVERTISED_BASE_URL` | (none) | URL devices use to reach this API, e.g. `http://192.168.1.50:8000`. Required for local updates; it cannot be guessed |
Expand Down Expand Up @@ -502,6 +512,10 @@ volumes:
shelly-manager-data:
```

Add `SHELLY_AUTH_TOKEN` to either example to require a bearer token on every request except
`/api/health` and `/api/auth/config`. It's optional and off by default; the Web UI shows a
login page for it automatically when it's set.

### Health Check

```yaml
Expand Down
39 changes: 39 additions & 0 deletions packages/api/src/api/controllers/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
Auth API routes for the optional shared auth token.
"""

from core.settings import settings
from litestar import Router, get

from ..guards.auth import require_auth


@get("/config", tags=["Auth"], summary="Auth Configuration")
async def get_auth_config() -> dict[str, bool]:
"""
Whether the API currently requires authentication.

Always public, since the Web UI needs to know whether to show the
login page before it has (or needs) a token of its own.

Returns:
dict: {"enabled": bool}
"""
return {"enabled": bool(settings.auth_token)}


@get("/verify", tags=["Auth"], summary="Verify Token", guards=[require_auth])
async def verify_token() -> dict[str, bool]:
"""
Confirm a bearer token is valid.

Only reached if the guard already accepted the Authorization header;
a missing/invalid token never reaches this handler.

Returns:
dict: {"valid": true}
"""
return {"valid": True}


auth_router = Router(path="/auth", route_handlers=[get_auth_config, verify_token])
Empty file.
26 changes: 26 additions & 0 deletions packages/api/src/api/guards/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Guard enforcing the optional shared auth token.
"""

import hmac

from core.domain.entities.exceptions import UnauthorizedError
from core.settings import settings
from litestar.connection import ASGIConnection
from litestar.handlers.base import BaseRouteHandler


def require_auth(connection: ASGIConnection, _: BaseRouteHandler) -> None:
"""Reject the request unless it carries the configured auth token.

A no-op when SHELLY_AUTH_TOKEN is unset, preserving the zero-config
default of no authentication.
"""
token = settings.auth_token
if not token:
return

header = connection.headers.get("authorization", "")
presented = header.removeprefix("Bearer ") if header.startswith("Bearer ") else ""
if not presented or not hmac.compare_digest(presented, token):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

hmac.compare_digest on str raises TypeError when either side contains non-ASCII, so a garbage Authorization header becomes a logged 500 instead of a 401. Can we compare presented.encode() against token.encode()?

raise UnauthorizedError()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nothing in core raises this, bearer transport is purely an API concern, so I'd rather keep it out of the domain layer. If the guard raises Litestar's NotAuthorizedException instead, the existing HTTPException handler already produces the same envelope, and both the core exception and the new EXCEPTION_HANDLERS entry can go.

14 changes: 11 additions & 3 deletions packages/api/src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from litestar.openapi.config import Contact, License, Server, Tag
from sqlalchemy import text

from .controllers.auth import auth_router
from .controllers.backup_schedules import backup_schedules_router
from .controllers.backups import backups_router
from .controllers.credentials import credentials_router
Expand All @@ -24,6 +25,7 @@
)
from .controllers.provisioning import provisioning_router
from .dependencies.container import APIContainer, get_dependencies
from .guards.auth import require_auth
from .presentation.handlers import EXCEPTION_HANDLERS
from .scheduler import BackupScheduler

Expand Down Expand Up @@ -52,6 +54,7 @@ def create_app() -> Litestar:
),
tags=[
Tag(name="Health", description="Service health and monitoring"),
Tag(name="Auth", description="Optional authentication gating the API"),
Tag(name="Devices", description="Device discovery and management"),
Tag(name="Components", description="Device component actions"),
Tag(name="Configuration", description="Device configuration management"),
Expand Down Expand Up @@ -84,8 +87,14 @@ def create_app() -> Litestar:
enabled_endpoints={"swagger", "openapi.json"},
)

api_router = Router(
public_router = Router(
path="/api",
route_handlers=[health_check, auth_router],
)

protected_router = Router(
path="/api",
guards=[require_auth],
route_handlers=[
devices_router,
credentials_router,
Expand All @@ -94,7 +103,6 @@ def create_app() -> Litestar:
backup_schedules_router,
firmware_router,
metadata_router,
health_check,
],
)

Expand Down Expand Up @@ -134,7 +142,7 @@ async def lifespan(app: Litestar) -> AsyncGenerator[None, None]:
await engine.dispose()

app = Litestar(
route_handlers=[api_router],
route_handlers=[public_router, protected_router],
cors_config=cors_config,
openapi_config=openapi_config,
exception_handlers=EXCEPTION_HANDLERS,
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/api/presentation/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
DeviceNotFoundError,
FirmwareConfigurationError,
FirmwareError,
UnauthorizedError,
)
from core.domain.entities.exceptions import ValidationError as CoreValidationError
from core.use_cases.backup_device_config import BackupError, BackupNotFoundError
Expand Down Expand Up @@ -91,6 +92,7 @@ def handle(request: Request, exc: Exception) -> Response:


EXCEPTION_HANDLERS: MutableMapping[int | type[Exception], ExceptionHandler] | None = {
UnauthorizedError: _typed_handler(401, "Unauthorized"),
DeviceAuthenticationError: _typed_handler(401, "Authentication Required"),
DeviceNotFoundError: handle_device_not_found_error,
DeviceCommunicationError: _typed_handler(502, "Device Communication Error"),
Expand Down
80 changes: 80 additions & 0 deletions packages/api/tests/unit/controllers/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import core.settings
from api.controllers.auth import auth_router
from api.presentation.handlers import EXCEPTION_HANDLERS
from litestar.testing import create_test_client


class TestAuthController:

def test_config_reports_disabled_when_no_token_is_set(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", None)

with create_test_client(route_handlers=[auth_router]) as client:
response = client.get("/auth/config")

assert response.status_code == 200
assert response.json() == {"enabled": False}

def test_config_reports_enabled_when_a_token_is_set(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with create_test_client(route_handlers=[auth_router]) as client:
response = client.get("/auth/config")

assert response.status_code == 200
assert response.json() == {"enabled": True}

def test_config_needs_no_header_even_when_a_token_is_set(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with create_test_client(route_handlers=[auth_router]) as client:
response = client.get("/auth/config")

assert response.status_code == 200

def test_verify_passes_through_when_auth_is_disabled(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", None)

with create_test_client(route_handlers=[auth_router]) as client:
response = client.get("/auth/verify")

assert response.status_code == 200
assert response.json() == {"valid": True}

def test_verify_rejects_a_missing_token(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with create_test_client(
route_handlers=[auth_router], exception_handlers=EXCEPTION_HANDLERS
) as client:
response = client.get("/auth/verify")

assert response.status_code == 401
assert response.json()["error"] == "Unauthorized"

def test_verify_rejects_the_wrong_token(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with create_test_client(
route_handlers=[auth_router], exception_handlers=EXCEPTION_HANDLERS
) as client:
response = client.get(
"/auth/verify", headers={"Authorization": "Bearer wrong"}
)

assert response.status_code == 401

def test_verify_accepts_the_correct_token(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with create_test_client(route_handlers=[auth_router]) as client:
response = client.get(
"/auth/verify", headers={"Authorization": "Bearer secret123"}
)

assert response.status_code == 200
assert response.json() == {"valid": True}

def test_it_is_mounted_on_the_app(self, app):
assert any(route.path == "/api/auth/config" for route in app.routes)
assert any(route.path == "/api/auth/verify" for route in app.routes)
41 changes: 41 additions & 0 deletions packages/api/tests/unit/test_guards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import core.settings
import pytest
from api.guards.auth import require_auth
from core.domain.entities.exceptions import UnauthorizedError
from litestar.testing import RequestFactory


def _connection(authorization: str | None = None):
headers = {"Authorization": authorization} if authorization else {}
return RequestFactory().get("/", headers=headers)


class TestRequireAuth:

def test_it_allows_the_request_when_auth_is_disabled(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", None)

require_auth(_connection(), None)

def test_it_rejects_a_missing_header_when_auth_is_enabled(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with pytest.raises(UnauthorizedError):
require_auth(_connection(), None)

def test_it_rejects_a_malformed_header(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with pytest.raises(UnauthorizedError):
require_auth(_connection(authorization="secret123"), None)

def test_it_rejects_the_wrong_token(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

with pytest.raises(UnauthorizedError):
require_auth(_connection(authorization="Bearer wrong"), None)

def test_it_allows_the_correct_token(self, monkeypatch):
monkeypatch.setattr(core.settings.settings, "auth_token", "secret123")

require_auth(_connection(authorization="Bearer secret123"), None)
Loading
Loading