From 81cf7be89aaf8278d5353a29bc4bfd20372a7963 Mon Sep 17 00:00:00 2001 From: Blake Blackshear Date: Mon, 9 Nov 2026 07:30:35 -0600 Subject: [PATCH] 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 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 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 Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde --------- Co-authored-by: Claude Opus 5.5 --- docs/static/frigate-api.yaml | 19 +++ frigate/api/app.py | 12 +- frigate/plus.py | 82 ++++++++++- frigate/test/http_api/test_http_plus.py | 51 +++++++ frigate/test/test_plus.py | 139 +++++++++++++++++- web/public/locales/en/views/settings.json | 9 +- .../settings/FrigatePlusSettingsView.tsx | 85 +++++++++-- 7 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 frigate/test/http_api/test_http_plus.py diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 85300dd272..da83bbab77 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -3148,6 +3148,25 @@ paths: - frigateUserAuth: [] x-required-role: any description: '**Access:** Any authenticated user.' + /plus/validate: + post: + tags: + - App + summary: Validate Plus Key + description: |- + **Access:** Admin role required. + + Validate the configured Frigate+ API key with the Frigate+ server. + operationId: validate_plus_key_plus_validate_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /recognized_license_plates: get: tags: diff --git a/frigate/api/app.py b/frigate/api/app.py index 3360598f95..3d0b4e0a45 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -54,7 +54,7 @@ from frigate.jobs.media_sync import ( start_media_sync_job, ) from frigate.models import Event, Timeline -from frigate.plus import load_plus_model_info +from frigate.plus import PlusKeyStatus, load_plus_model_info from frigate.stats.prometheus import get_metrics, update_metrics from frigate.types import JobStatusTypesEnum from frigate.util.builtin import ( @@ -1675,6 +1675,16 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False): return JSONResponse(content=validModels) +@router.post("/plus/validate", dependencies=[Depends(require_role(["admin"]))]) +async def validate_plus_key(request: Request): + """Validate the configured Frigate+ API key with the Frigate+ server.""" + status = await request.app.frigate_config.plus_api.validate_key() + + return JSONResponse( + content={"success": status == PlusKeyStatus.valid, "status": status.value} + ) + + @router.get( "/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())] ) diff --git a/frigate/plus.py b/frigate/plus.py index 84880c296f..cbb70398d4 100644 --- a/frigate/plus.py +++ b/frigate/plus.py @@ -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: diff --git a/frigate/test/http_api/test_http_plus.py b/frigate/test/http_api/test_http_plus.py new file mode 100644 index 0000000000..632a84acdb --- /dev/null +++ b/frigate/test/http_api/test_http_plus.py @@ -0,0 +1,51 @@ +"""Tests for the Frigate+ API endpoints.""" + +import unittest +from unittest.mock import patch + +from frigate.models import Event +from frigate.plus import PlusKeyStatus +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpPlusValidate(BaseTestHttp): + def setUp(self): + super().setUp([Event]) + + def _validate(self, status: PlusKeyStatus) -> dict: + app = self.create_app() + + with patch.object( + app.frigate_config.plus_api, "validate_key", return_value=status + ): + with AuthTestClient(app) as client: + response = client.post("/plus/validate") + + self.assertEqual(response.status_code, 200) + return response.json() + + def test_a_valid_key(self): + self.assertEqual( + self._validate(PlusKeyStatus.valid), {"success": True, "status": "valid"} + ) + + def test_an_invalid_key(self): + self.assertEqual( + self._validate(PlusKeyStatus.invalid), + {"success": False, "status": "invalid"}, + ) + + def test_viewers_cannot_validate(self): + app = self.create_app() + + with AuthTestClient(app) as client: + response = client.post( + "/plus/validate", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + ) + + self.assertEqual(response.status_code, 403) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/frigate/test/test_plus.py b/frigate/test/test_plus.py index 33b291640d..c356f4f624 100644 --- a/frigate/test/test_plus.py +++ b/frigate/test/test_plus.py @@ -1,12 +1,17 @@ """Tests for Frigate+ model metadata.""" +import asyncio import json import os import tempfile import unittest from unittest.mock import MagicMock, patch -from frigate.plus import PlusApi, load_plus_model_info +import aiohttp +from aiohttp import web +from aiohttp.test_utils import TestServer + +from frigate.plus import PlusApi, PlusKeyStatus, load_plus_model_info class TestHailoAlias(unittest.TestCase): @@ -75,5 +80,137 @@ class TestLoadPlusModelInfo(unittest.TestCase): self.assertIsNone(load_plus_model_info("bad")) +class TestValidateKey(unittest.IsolatedAsyncioTestCase): + """The key format is checked locally, but only the server knows if it is valid.""" + + CACHED_TOKEN = {"accessToken": "cached", "expires": 2000000000} + + async def _serve(self, handler) -> str: + """Serve the token endpoint locally and return its base url.""" + app = web.Application() + app.router.add_get("/v1/auth/token", handler) + server = TestServer(app) + await server.start_server() + self.addAsyncCleanup(server.close) + return str(server.make_url("")).rstrip("/") + + def _api(self, host: str, key: str | None = "id:secret") -> PlusApi: + api = PlusApi.__new__(PlusApi) + api.host = host + api.key = key + api._token_data = dict(self.CACHED_TOKEN) + return api + + async def _validate(self, handler) -> tuple[PlusKeyStatus, PlusApi]: + api = self._api(await self._serve(handler)) + return await api.validate_key(), api + + def _replies(self, status: int = 200, body: object = None, text: str | None = None): + async def handler(request: web.Request) -> web.Response: + self.auth = request.headers.get("Authorization") + if text is not None: + return web.Response(status=status, text=text) + return web.json_response(body, status=status) + + return handler + + async def test_an_accepted_key_is_valid(self): + token = {"accessToken": "fresh", "expires": 2000000500.5} + status, api = await self._validate(self._replies(body=token)) + + self.assertEqual(status, PlusKeyStatus.valid) + self.assertEqual(api._token_data, token) + self.assertEqual(self.auth, aiohttp.BasicAuth("id", "secret").encode()) + + async def test_a_rejected_key_is_invalid(self): + for code in (401, 403): + status, api = await self._validate(self._replies(code, {"error": "no"})) + + self.assertEqual(status, PlusKeyStatus.invalid) + self.assertEqual(api._token_data, self.CACHED_TOKEN) + + async def test_a_server_error_is_not_reported_as_invalid(self): + status, _ = await self._validate(self._replies(500, {"error": "down"})) + + self.assertEqual(status, PlusKeyStatus.unreachable) + + async def test_unusable_replies_keep_the_cached_token(self): + # a successful reply without a usable token must not replace one that + # later Frigate+ requests depend on + for body in ( + None, + [], + {}, + {"accessToken": "t"}, + {"expires": 2000000000}, + {"accessToken": 1, "expires": 2000000000}, + {"accessToken": "t", "expires": "soon"}, + {"accessToken": "t", "expires": True}, + ): + with self.subTest(body=body): + status, api = await self._validate(self._replies(body=body)) + + self.assertEqual(status, PlusKeyStatus.unreachable) + self.assertEqual(api._token_data, self.CACHED_TOKEN) + + async def test_a_non_json_reply_keeps_the_cached_token(self): + status, api = await self._validate(self._replies(text="")) + + self.assertEqual(status, PlusKeyStatus.unreachable) + self.assertEqual(api._token_data, self.CACHED_TOKEN) + + async def test_a_connection_failure_is_unreachable(self): + # serve and stop, so the port refuses connections + server = TestServer(web.Application()) + await server.start_server() + host = str(server.make_url("")).rstrip("/") + await server.close() + api = self._api(host) + + self.assertEqual(await api.validate_key(), PlusKeyStatus.unreachable) + + async def test_a_slow_server_times_out_as_unreachable(self): + async def slow(request: web.Request) -> web.Response: + await asyncio.sleep(5) + return web.json_response(self.CACHED_TOKEN) + + with patch("frigate.plus.PLUS_VALIDATE_TIMEOUT", 0.1): + status, _ = await self._validate(slow) + + self.assertEqual(status, PlusKeyStatus.unreachable) + + async def test_the_request_goes_through_a_configured_proxy(self): + # installs that reach Frigate+ through a proxy must validate the same way + token = {"accessToken": "proxied", "expires": 2000000000} + proxied = [] + + async def proxy(request: web.Request) -> web.Response: + proxied.append(str(request.url)) + return web.json_response(token) + + proxy_url = await self._serve(proxy) + api = self._api("http://plus.invalid") + + with patch.dict( + os.environ, {"HTTP_PROXY": proxy_url, "NO_PROXY": ""}, clear=False + ): + status = await api.validate_key() + + self.assertEqual(status, PlusKeyStatus.valid) + self.assertEqual(proxied, ["http://plus.invalid/v1/auth/token"]) + + async def test_no_key_is_not_sent_to_the_server(self): + requests_seen = [] + + async def handler(request: web.Request) -> web.Response: + requests_seen.append(request) + return web.json_response(self.CACHED_TOKEN) + + api = self._api(await self._serve(handler), key=None) + + self.assertEqual(await api.validate_key(), PlusKeyStatus.not_set) + self.assertEqual(requests_seen, []) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a789a87072..4876977ff2 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1285,8 +1285,13 @@ }, "apiKey": { "title": "Frigate+ API Key", - "validated": "Frigate+ API key is detected and validated", - "notValidated": "Frigate+ API key is not detected or not validated", + "formatValid": "Frigate+ API key is detected and matches the expected format", + "formatInvalid": "Frigate+ API key is not detected or does not match the expected format", + "validate": "Validate with Frigate+", + "validating": "Validating…", + "serverValid": "Frigate+ accepted this API key", + "serverInvalid": "Frigate+ rejected this API key", + "serverUnreachable": "Unable to reach Frigate+ to validate this API key", "desc": "The Frigate+ API key enables integration with the Frigate+ service.", "plusLink": "Read more about Frigate+" }, diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx index 47001c36b9..b20354fa7f 100644 --- a/web/src/views/settings/FrigatePlusSettingsView.tsx +++ b/web/src/views/settings/FrigatePlusSettingsView.tsx @@ -1,8 +1,9 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import useSWR from "swr"; -import { CheckCircle2, XCircle } from "lucide-react"; +import axios from "axios"; +import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react"; import { LuExternalLink } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; @@ -18,12 +19,34 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { isReplayCamera } from "@/utils/cameraUtil"; import type { SettingsPageProps } from "@/views/settings/SingleSectionPage"; import { getPrimaryModel } from "@/utils/modelUtil"; +import { useIsAdmin } from "@/hooks/use-is-admin"; + +type PlusKeyStatus = "valid" | "invalid" | "unreachable" | "not_set"; + +type PlusValidateResponse = { success: boolean; status: PlusKeyStatus }; export default function FrigatePlusSettingsView(_props: SettingsPageProps) { const { t } = useTranslation("views/settings"); const { getLocaleDocUrl } = useDocDomain(); const { data: config } = useSWR("config"); const navigate = useNavigate(); + const isAdmin = useIsAdmin(); + + const [isValidating, setIsValidating] = useState(false); + const [keyStatus, setKeyStatus] = useState(); + + const validateKey = useCallback(async () => { + setIsValidating(true); + + try { + const response = await axios.post("plus/validate"); + setKeyStatus(response.data.status); + } catch { + setKeyStatus("unreachable"); + } finally { + setIsValidating(false); + } + }, []); useEffect(() => { document.title = t("documentTitle.frigatePlus"); @@ -68,17 +91,55 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) { } content={ -
- {config?.plus?.enabled ? ( - - ) : ( - +
+
+ {config?.plus?.enabled ? ( + + ) : ( + + )} + + {config?.plus?.enabled + ? t("frigatePlus.apiKey.formatValid") + : t("frigatePlus.apiKey.formatInvalid")} + +
+ {config?.plus?.enabled && isAdmin && ( +
+ + {keyStatus && !isValidating && ( +
+ {keyStatus === "valid" ? ( + + ) : keyStatus === "unreachable" ? ( + + ) : ( + + )} + + {keyStatus === "valid" + ? t("frigatePlus.apiKey.serverValid") + : keyStatus === "unreachable" + ? t("frigatePlus.apiKey.serverUnreachable") + : t("frigatePlus.apiKey.serverInvalid")} + +
+ )} +
)} - - {config?.plus?.enabled - ? t("frigatePlus.apiKey.validated") - : t("frigatePlus.apiKey.notValidated")} -
} />