Add on-demand Frigate+ API key validation (#24918)

* Add on-demand Frigate+ API key validation

The Frigate+ settings page reported the API key as validated whenever it
matched the expected format, without ever checking it with the server.

- Clarify the status text to say the key matches the expected format
- Add PlusApi.validate_key(), which requests a token from Frigate+ and
  reports the key as valid, invalid, unreachable, or not set; server
  errors and timeouts are not reported as an invalid key
- Add an admin-only POST /plus/validate endpoint and regenerate the API spec
- Add a "Validate with Frigate+" button that shows the result inline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Validate Frigate+ key asynchronously and keep cached token on bad replies

- Use aiohttp for the validation request so the endpoint awaits it
  instead of blocking a worker thread
- Only replace the cached token when the reply has a string accessToken
  and numeric expires; otherwise report unreachable so an unexpected
  response can't break later Frigate+ requests
- Test against a local aiohttp server, covering timeouts, connection
  failures, non-JSON replies and unusable token shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

* Honor proxy settings when validating the Frigate+ key

aiohttp ignores HTTP(S)_PROXY unless trust_env is set, so validation
reported Frigate+ as unreachable on installs that need a proxy while
other Frigate+ requests still worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-11-09 06:30:35 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 52d469ed20
commit 81cf7be89a
7 changed files with 380 additions and 17 deletions
+81 -1
View File
@@ -3,9 +3,11 @@ import json
import logging
import os
import re
from enum import Enum
from pathlib import Path
from typing import Any
from typing import Any, TypeGuard
import aiohttp
import cv2
import requests
from numpy import ndarray
@@ -17,6 +19,17 @@ from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
logger = logging.getLogger(__name__)
PLUS_VALIDATE_TIMEOUT = 10
class PlusKeyStatus(str, Enum):
"""Result of validating a Frigate+ API key with the server."""
valid = "valid"
invalid = "invalid"
unreachable = "unreachable"
not_set = "not_set"
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
"""Name the hailo detector by its current key as well as its old one.
@@ -72,6 +85,19 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
def _is_usable_token(token_data: Any) -> TypeGuard[dict[str, Any]]:
"""Whether a Frigate+ token response has a usable token and expiry."""
if not isinstance(token_data, dict):
return False
expires = token_data.get("expires")
return (
isinstance(token_data.get("accessToken"), str)
and isinstance(expires, (int, float))
and not isinstance(expires, bool)
)
class PlusApi:
def __init__(self) -> None:
self.host = PLUS_API_HOST
@@ -153,6 +179,60 @@ class PlusApi:
def is_active(self) -> bool:
return self._is_active
async def validate_key(self) -> PlusKeyStatus:
"""Check the configured API key against the Frigate+ server.
The key format is checked at startup, but only the server can tell
whether the key is actually valid. This requests a fresh token so the
result reflects the key's current state rather than a cached token.
Returns:
The key's status as reported by the Frigate+ server
"""
if self.key is None:
return PlusKeyStatus.not_set
key_id, _, secret = self.key.partition(":")
# Use a single request with a timeout instead of the retrying session,
# since this is user initiated and should fail fast when unreachable.
# trust_env honors proxy settings, matching the other Frigate+ requests.
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=PLUS_VALIDATE_TIMEOUT),
trust_env=True,
) as session:
async with session.get(
f"{self.host}/v1/auth/token",
auth=aiohttp.BasicAuth(key_id, secret),
) as r:
status = r.status
token_data = await r.json(content_type=None) if r.ok else None
except (aiohttp.ClientError, TimeoutError) as err:
logger.warning("Unable to reach Frigate+ to validate API key: %s", err)
return PlusKeyStatus.unreachable
except ValueError:
logger.warning("Invalid response validating Frigate+ API key")
return PlusKeyStatus.unreachable
if status in (401, 403):
return PlusKeyStatus.invalid
if status >= 400:
logger.warning(
"Unexpected response validating Frigate+ API key: %s", status
)
return PlusKeyStatus.unreachable
# Keep the cached token unless the reply is a usable one, so an
# unexpected response can't break later Frigate+ requests
if not _is_usable_token(token_data):
logger.warning("Invalid response validating Frigate+ API key")
return PlusKeyStatus.unreachable
self._token_data = token_data
return PlusKeyStatus.valid
def upload_image(self, image: ndarray, camera: str) -> str:
r = self._get("image/signed_urls")
if not r.ok: