Browse Source

Add API key auth support to /auth/me for SpoolBuddy kiosk

When Bambuddy auth is enabled, the SpoolBuddy kiosk gets redirected to
the login page because ProtectedRoute requires a user from GET /auth/me,
which only handled JWT tokens. The kiosk daemon already has an API key
but couldn't use it to satisfy the frontend auth check.

- Backend: /auth/me now accepts API keys (Bearer bb_xxx or X-API-Key)
  and returns a synthetic admin UserResponse with all permissions
- Frontend: AuthContext reads ?token= from URL on first load, stores in
  localStorage, and strips from URL (prevents history/referrer leakage)
- Install script: kiosk URL now includes ?token=${API_KEY}
- Tests: 3 new integration tests (Bearer API key, X-API-Key header,
  invalid key rejection)
maziggy 2 months ago
parent
commit
42b07d8bfd

+ 2 - 1
CHANGELOG.md

@@ -2,11 +2,12 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
-## [0.2.2b1] - Unreleased
+## [0.2.2b1] - Unrelased
 
 ### New Features
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
 - **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and a compact printers list with live status indicators; right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card.
+- **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 
 ### Fixed
 - **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.

+ 89 - 7
backend/app/api/routes/auth.py

@@ -1,6 +1,8 @@
 from datetime import timedelta
+from typing import Annotated
 
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, Header, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -8,8 +10,11 @@ from sqlalchemy.orm import selectinload
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
+    ALGORITHM,
+    SECRET_KEY,
     Permission,
     RequirePermissionIfAuthEnabled,
+    _validate_api_key,
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
@@ -17,8 +22,10 @@ from backend.app.core.auth import (
     get_password_hash,
     get_user_by_email,
     get_user_by_username,
+    security,
 )
 from backend.app.core.database import get_db
+from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -61,6 +68,21 @@ def _user_to_response(user: User) -> UserResponse:
     )
 
 
+def _api_key_to_user_response(api_key) -> UserResponse:
+    """Create a synthetic admin UserResponse for a valid API key."""
+    return UserResponse(
+        id=0,
+        username=f"api-key:{api_key.key_prefix}",
+        email=None,
+        role="admin",
+        is_active=True,
+        is_admin=True,
+        groups=[],
+        permissions=sorted(ALL_PERMISSIONS),
+        created_at=api_key.created_at.isoformat(),
+    )
+
+
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
 
@@ -308,14 +330,74 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
 
 @router.get("/me", response_model=UserResponse)
 async def get_current_user_info(
-    current_user: User = Depends(get_current_active_user),
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get current user information."""
-    # Reload user with groups for proper permission calculation
-    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
-    user = result.scalar_one()
-    return _user_to_response(user)
+    """Get current user information.
+
+    Accepts JWT tokens (via Authorization: Bearer header) and API keys
+    (via X-API-Key header or Authorization: Bearer bb_xxx).
+    API keys return a synthetic admin user with all permissions.
+    """
+    import jwt
+    from jwt.exceptions import PyJWTError as JWTError
+
+    # Check for API key via X-API-Key header
+    if x_api_key:
+        api_key = await _validate_api_key(db, x_api_key)
+        if api_key:
+            return _api_key_to_user_response(api_key)
+
+    # Check for Bearer token (could be JWT or API key)
+    if credentials is not None:
+        token = credentials.credentials
+        # Check if it's an API key (starts with bb_)
+        if token.startswith("bb_"):
+            api_key = await _validate_api_key(db, token)
+            if api_key:
+                return _api_key_to_user_response(api_key)
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Invalid API key",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+        # Otherwise treat as JWT
+        try:
+            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+            username: str = payload.get("sub")
+            if username is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+        except JWTError:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+        user = await get_user_by_username(db, username)
+        if user is None or not user.is_active:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+        # Reload with groups for proper permission calculation
+        result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+        user = result.scalar_one()
+        return _user_to_response(user)
+
+    # No credentials provided
+    raise HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Authentication required",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
 
 
 @router.post("/logout")

+ 62 - 0
backend/tests/integration/test_auth_api.py

@@ -180,6 +180,68 @@ class TestAuthMeAPI:
         assert result["role"] == "admin"
         assert result["is_active"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session):
+        """Verify /me returns synthetic admin user when using API key via Bearer token."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        # Create an API key directly in the database
+        full_key, key_hash, key_prefix = generate_api_key()
+        api_key = APIKey(name="test-kiosk", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
+        db_session.add(api_key)
+        await db_session.commit()
+
+        # Call /me with the API key as Bearer token
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": f"Bearer {full_key}"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == 0
+        assert result["username"].startswith("api-key:")
+        assert result["role"] == "admin"
+        assert result["is_admin"] is True
+        assert result["is_active"] is True
+        assert len(result["permissions"]) > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session):
+        """Verify /me returns synthetic admin user when using X-API-Key header."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        api_key = APIKey(name="test-kiosk-header", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
+        db_session.add(api_key)
+        await db_session.commit()
+
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"X-API-Key": full_key},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == 0
+        assert result["username"].startswith("api-key:")
+        assert result["is_admin"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_invalid_api_key(self, async_client: AsyncClient):
+        """Verify /me rejects invalid API key."""
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": "Bearer bb_invalid_key_value"},
+        )
+
+        assert response.status_code == 401
+
 
 class TestUsersAPI:
     """Integration tests for /api/v1/users/ endpoints."""

+ 14 - 0
frontend/src/contexts/AuthContext.tsx

@@ -30,6 +30,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 
   const checkAuthStatus = async () => {
     try {
+      // Bootstrap: if URL has ?token= param, store it and strip from URL.
+      // Allows SpoolBuddy kiosk to pass API key via URL on first load.
+      const urlParams = new URLSearchParams(window.location.search);
+      const urlToken = urlParams.get('token');
+      if (urlToken) {
+        setAuthToken(urlToken);
+        urlParams.delete('token');
+        const cleanSearch = urlParams.toString();
+        const cleanUrl = window.location.pathname
+          + (cleanSearch ? `?${cleanSearch}` : '')
+          + window.location.hash;
+        window.history.replaceState({}, '', cleanUrl);
+      }
+
       const status = await api.getAuthStatus();
       if (!mountedRef.current) return;
       setAuthEnabled(status.auth_enabled);

+ 2 - 2
spoolbuddy/install/install.sh

@@ -55,7 +55,7 @@ BAMBUDDY_PORT="8000"
 NON_INTERACTIVE="false"
 REBOOT_NEEDED="false"
 KIOSK_USER=""            # auto-detected from $SUDO_USER
-KIOSK_URL=""             # derived from $BAMBUDDY_URL/spoolbuddy
+KIOSK_URL=""             # derived from $BAMBUDDY_URL/spoolbuddy?token=$API_KEY
 
 # ─────────────────────────────────────────────────────────────────────────────
 # Helpers
@@ -648,7 +648,7 @@ setup_kiosk() {
 
     # Detect kiosk user (the human user who ran sudo)
     KIOSK_USER="${SUDO_USER:-$(logname 2>/dev/null || echo pi)}"
-    KIOSK_URL="${BAMBUDDY_URL}/spoolbuddy"
+    KIOSK_URL="${BAMBUDDY_URL}/spoolbuddy?token=${API_KEY}"
     local KIOSK_HOME
     KIOSK_HOME=$(eval echo "~$KIOSK_USER")
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DYx-SdRP.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C6P1sMLA.js"></script>
+    <script type="module" crossorigin src="/assets/index-DYx-SdRP.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DAOWLKX-.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff